刘光辉
13 小时以前 34981c30a78e8bbd7791131059a9210f9928b62c
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
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
package jnpf.bizcommon.onlyoffice.service.impl;
 
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.jwt.JWTUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import jnpf.base.UserInfo;
import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeCallbackParam;
import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeConfigParam;
import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeProperties;
import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeSessionEntity;
import jnpf.bizcommon.onlyoffice.mapper.OnlyOfficeSessionMapper;
import jnpf.bizcommon.onlyoffice.service.OnlyOfficeService;
import jnpf.constant.PermissionConst;
import jnpf.exception.DataException;
import jnpf.file.FileApi;
import jnpf.file.FileUploadApi;
import jnpf.util.UserProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import org.springframework.web.multipart.MultipartFile;
 
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
 
/**
 * 编辑器 config 的签发。
 *
 * <p>本类只负责「打开文档」这一段。三段链路的凭据各不相同:打开走用户登录 token,
 * DS 回源走 securityKey 票据,DS 回调走本类签发的同一把 JWT 密钥。
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class OnlyOfficeServiceImpl implements OnlyOfficeService {
 
    private final OnlyOfficeProperties properties;
    private final OnlyOfficeSessionMapper sessionMapper;
    private final OnlyOfficePluginResolver pluginResolver;
    private final OnlyOfficeFillTicketIssuer fillTicketIssuer;
    private final FileApi fileApi;
    private final FileUploadApi fileUploadApi;
 
    /** 扩展名 → DS documentType;未列出的一律拒绝,避免把不支持的格式喂给编辑器 */
    private static final Map<String, String> DOCUMENT_TYPES = new HashMap<>();
 
    static {
        putAll("word", "doc", "docx", "docm", "dot", "dotx", "odt", "ott", "rtf", "txt", "html", "htm");
        putAll("cell", "xls", "xlsx", "xlsm", "xlt", "xltx", "ods", "ots", "csv");
        putAll("slide", "ppt", "pptx", "pptm", "pot", "potx", "pps", "ppsx", "odp", "otp");
        putAll("pdf", "pdf");
    }
 
    private static void putAll(String documentType, String... extensions) {
        for (String extension : extensions) {
            DOCUMENT_TYPES.put(extension, documentType);
        }
    }
 
    /** 可编辑的格式;其余(如 pdf、txt 之外的只读格式)一律降级为只读 */
    private static final Set<String> EDITABLE_EXTENSIONS = new HashSet<>(Arrays.asList(
            "docx", "xlsx", "pptx", "docm", "xlsm", "pptm", "odt", "ods", "odp", "rtf", "txt", "csv"));
 
    private static final String MODE_VIEW = "view";
    private static final String MODE_EDIT = "edit";
    private static final String ELN_TEMPLATE_ANNOTATE_SCENE = "eln.template.annotate";
 
    @Override
    public Map<String, Object> buildEditorConfig(OnlyOfficeConfigParam param) {
        if (ObjectUtils.isEmpty(param.getFileId()) || ObjectUtils.isEmpty(param.getFileName())) {
            throw new DataException("fileId 与 fileName 不能为空");
        }
        requireJwtSecret();
 
        String extension = extensionOf(param.getFileName());
        String documentType = DOCUMENT_TYPES.get(extension);
        if (documentType == null) {
            throw new DataException("OnlyOffice 不支持的文件格式:" + extension);
        }
 
        List<OnlyOfficePluginResolver.ResolvedPlugin> resolvedPlugins = pluginResolver.resolve(param);
        UserInfo user = UserProvider.getUser();
        OnlyOfficeSessionEntity session = loadOrCreateSession(param, documentType);
        String mode = resolveMode(param, extension, session, user);
        if (MODE_EDIT.equals(mode)) {
            acquireLock(session, user.getUserId());
        }
 
        return assembleConfig(param, session, documentType, extension, mode, user, resolvedPlugins);
    }
 
    @Override
    public Map<String, Object> getSaveStatus(String fileId) {
        if (ObjectUtils.isEmpty(fileId)) {
            throw new DataException("fileId 不能为空");
        }
        OnlyOfficeSessionEntity session = sessionMapper.selectOne(Wrappers.<OnlyOfficeSessionEntity>lambdaQuery()
                .eq(OnlyOfficeSessionEntity::getFileId, fileId)
                .last("limit 1"));
        Map<String, Object> status = new LinkedHashMap<>();
        status.put("version", session == null || session.getVersionNo() == null ? 0 : session.getVersionNo());
        status.put("lastSaveTime", session == null || session.getLastSaveTime() == null
                ? null : session.getLastSaveTime().getTime());
        return status;
    }
 
    // ── 保存回调 ────────────────────────────────────────────────────────────
 
    @Override
    public void handleCallback(OnlyOfficeCallbackParam param, String authorization) {
        verifyCallbackToken(param, authorization);
 
        if (ObjectUtils.isEmpty(param.getKey()) || param.getStatus() == null) {
            throw new DataException("回调缺少 key 或 status");
        }
        OnlyOfficeSessionEntity session = sessionMapper.selectOne(Wrappers.<OnlyOfficeSessionEntity>lambdaQuery()
                .eq(OnlyOfficeSessionEntity::getDocKey, param.getKey())
                .last("limit 1"));
        if (session == null) {
            // 会话被清理或 key 对不上。抛错让 Controller 记日志,但仍返回 error:0——
            // 返回非 0 会让 DS 无限重试一个永远不会成功的回调。
            throw new DataException("找不到 doc_key 对应的编辑会话:" + param.getKey());
        }
 
        switch (param.getStatus()) {
            case OnlyOfficeCallbackParam.Status.EDITING:
                refreshLockHeartbeat(session);
                break;
            case OnlyOfficeCallbackParam.Status.READY_FOR_SAVING:
                saveBack(session, param, true);
                break;
            case OnlyOfficeCallbackParam.Status.FORCE_SAVING:
                // 强制保存:落盘但不结束会话,故不轮换 key、不释放锁
                saveBack(session, param, false);
                break;
            case OnlyOfficeCallbackParam.Status.CLOSED_NO_CHANGES:
                releaseLock(session, OnlyOfficeSessionEntity.SessionStatus.IDLE);
                break;
            case OnlyOfficeCallbackParam.Status.SAVE_ERROR:
            case OnlyOfficeCallbackParam.Status.FORCE_SAVE_ERROR:
                log.error("OnlyOffice 保存出错,file={} key={} status={}",
                        session.getFileId(), param.getKey(), param.getStatus());
                releaseLock(session, OnlyOfficeSessionEntity.SessionStatus.SAVE_FAILED);
                break;
            default:
                log.warn("未知的 OnlyOffice 回调 status={},key={}", param.getStatus(), param.getKey());
        }
    }
 
    /**
     * 验签。回调路径在网关白名单内、匿名可达,这是唯一的安全边界,
     * 验不过一律抛错,不得降级放过。
     */
    private void verifyCallbackToken(OnlyOfficeCallbackParam param, String authorization) {
        String token = null;
        if (!ObjectUtils.isEmpty(authorization)) {
            token = authorization.startsWith("Bearer ") ? authorization.substring(7).trim() : authorization.trim();
        }
        if (ObjectUtils.isEmpty(token)) {
            // 少数部署把 token 放在 body 而非 Authorization 头
            token = param.getToken();
        }
        if (ObjectUtils.isEmpty(token)) {
            throw new DataException("回调缺少 JWT,拒绝处理");
        }
        if (!JWTUtil.verify(token, requireJwtSecret().getBytes(StandardCharsets.UTF_8))) {
            throw new DataException("回调 JWT 验签失败,拒绝处理");
        }
    }
 
    /**
     * 把 DS 产出的新版文档写回文件存储。
     *
     * <p>原地覆盖同一个 fileId——这样表单里存的附件字段 JSON 完全不用改写,
     * 代价只是前端侧的 fileSize 会过期(靠 onSave 回调刷新)。
     *
     * @param rotateKey 是否轮换 doc_key 并释放锁。status 2(全员退出)为 true,
     *                  status 6(强制保存,仍在编辑)为 false
     */
    private void saveBack(OnlyOfficeSessionEntity session, OnlyOfficeCallbackParam param, boolean rotateKey) {
        if (ObjectUtils.isEmpty(param.getUrl())) {
            throw new DataException("回调 status=" + param.getStatus() + " 但缺少下载 url");
        }
        String downloadUrl = rewriteToInternalHost(param.getUrl());
        try (HttpResponse response = HttpUtil.createGet(downloadUrl).timeout(60_000).executeAsync()) {
            if (!response.isOk()) {
                throw new DataException("从 DS 下载编辑后文档失败,HTTP " + response.getStatus());
            }
            MultipartFile file = new InMemoryMultipartFile(session.getFileId(), response.bodyBytes());
            String folderPath = fileApi.getPath(session.getFileType());
            if (ObjectUtils.isEmpty(folderPath)) {
                throw new DataException("文件服务无法解析文档存储目录");
            }
            if (!folderPath.endsWith("/") && !folderPath.endsWith("\\")) {
                folderPath += "/";
            }
            if (!fileUploadApi.replaceFile(file, folderPath, session.getFileId())) {
                throw new DataException("文件服务写回文档失败");
            }
        } catch (DataException e) {
            throw e;
        } catch (Exception e) {
            throw new DataException("写回文档失败:" + e.getMessage());
        }
 
        int nextVersion = session.getVersionNo() == null ? 1 : session.getVersionNo() + 1;
        session.setLastSaveTime(new Date());
        session.setLastEditorIds(param.getUsers() == null ? null : String.join(",", param.getUsers()));
        if (rotateKey) {
            // 内容变了必须换 key,否则下次打开 DS 会命中自身缓存返回旧内容
            session.setVersionNo(nextVersion);
            session.setDocKey(buildDocKey(session.getFileId(), nextVersion));
            session.setLockUserId(null);
            session.setLockHeartbeat(null);
            session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE);
        }
        sessionMapper.updateById(session);
        log.info("OnlyOffice 保存回写完成,file={} version={} rotateKey={}",
                session.getFileId(), session.getVersionNo(), rotateKey);
    }
 
    private static final class InMemoryMultipartFile implements MultipartFile {
 
        private final String fileName;
        private final byte[] content;
 
        private InMemoryMultipartFile(String fileName, byte[] content) {
            this.fileName = fileName;
            this.content = content;
        }
 
        @Override
        public String getName() {
            return "multipartFile";
        }
 
        @Override
        public String getOriginalFilename() {
            return fileName;
        }
 
        @Override
        public String getContentType() {
            return "application/octet-stream";
        }
 
        @Override
        public boolean isEmpty() {
            return content.length == 0;
        }
 
        @Override
        public long getSize() {
            return content.length;
        }
 
        @Override
        public byte[] getBytes() {
            return content;
        }
 
        @Override
        public InputStream getInputStream() {
            return new ByteArrayInputStream(content);
        }
 
        @Override
        public void transferTo(File destination) throws IOException {
            Files.write(destination.toPath(), content);
        }
    }
 
    /**
     * DS 给的下载 url 用的是它自己视角的主机名,后端不一定能直连。
     * 配了 ds-internal-url 就只保留 path+query 换到后端可达的主机上。
     */
    private String rewriteToInternalHost(String url) {
        String base = properties.getDsInternalUrl();
        if (ObjectUtils.isEmpty(base)) {
            return url;
        }
        try {
            URI origin = URI.create(url);
            String pathAndQuery = origin.getRawPath()
                    + (origin.getRawQuery() == null ? "" : "?" + origin.getRawQuery());
            String rewritten = base.endsWith("/") ? base.substring(0, base.length() - 1) + pathAndQuery
                    : base + pathAndQuery;
            if (!rewritten.equals(url)) {
                log.debug("DS 下载地址重写:{} → {}", url, rewritten);
            }
            return rewritten;
        } catch (IllegalArgumentException e) {
            log.warn("DS 下载地址无法解析,原样使用:{}", url);
            return url;
        }
    }
 
    private void refreshLockHeartbeat(OnlyOfficeSessionEntity session) {
        session.setLockHeartbeat(new Date());
        session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING);
        sessionMapper.updateById(session);
    }
 
    private void releaseLock(OnlyOfficeSessionEntity session, int status) {
        session.setLockUserId(null);
        session.setLockHeartbeat(null);
        session.setSessionStatus(status);
        sessionMapper.updateById(session);
    }
 
    // ── 会话与编辑锁 ─────────────────────────────────────────────────────────
 
    /**
     * 取当前文件的会话;没有则建一条 version_no=1 的新会话。
     * docKey 由 fileId + versionNo 派生,保证同一版本重复打开拿到相同 key(否则 DS 不认为是同一文档)。
     */
    private OnlyOfficeSessionEntity loadOrCreateSession(OnlyOfficeConfigParam param, String documentType) {
        OnlyOfficeSessionEntity session = sessionMapper.selectOne(Wrappers.<OnlyOfficeSessionEntity>lambdaQuery()
                .eq(OnlyOfficeSessionEntity::getFileId, param.getFileId())
                .eq(OnlyOfficeSessionEntity::getFileType, param.getFileType())
                .last("limit 1"));
        if (session != null) {
            // 业务上下文可能在后续调用里才补全(如先从附件列表打开、后从业务页面打开)
            if (!ObjectUtils.isEmpty(param.getBizModule())) {
                session.setBizModule(param.getBizModule());
            }
            if (!ObjectUtils.isEmpty(param.getBizDataId())) {
                session.setBizDataId(param.getBizDataId());
            }
            session.setFileName(param.getFileName());
            session.setDocumentType(documentType);
            sessionMapper.updateById(session);
            return session;
        }
 
        session = new OnlyOfficeSessionEntity();
        session.setId(IdUtil.getSnowflakeNextIdStr());
        session.setFileId(param.getFileId());
        session.setFileType(param.getFileType());
        session.setFileName(param.getFileName());
        session.setDocumentType(documentType);
        session.setBizModule(param.getBizModule());
        session.setBizDataId(param.getBizDataId());
        session.setVersionNo(1);
        session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE);
        session.setDocKey(buildDocKey(param.getFileId(), 1));
        session.setDeleteMark(null);
        sessionMapper.insert(session);
        return session;
    }
 
    /**
     * 决定最终模式。前端传的只是期望值,以下四种情况一律降级为只读:
     * 格式本身不可编辑、当前用户无编辑权、他人正持有编辑锁、DS 上一次保存失败
     * (避免在错误状态上继续写)。
     */
    private String resolveMode(OnlyOfficeConfigParam param, String extension, OnlyOfficeSessionEntity session, UserInfo user) {
        if (ObjectUtils.isEmpty(param.getBizScene())) {
            return MODE_VIEW;
        }
        if (MODE_VIEW.equals(param.getMode())) {
            return MODE_VIEW;
        }
        if (!EDITABLE_EXTENSIONS.contains(extension)) {
            return MODE_VIEW;
        }
        if (!hasEditPermission(param.getBizModule(), user)) {
            return MODE_VIEW;
        }
        if (Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.SAVE_FAILED).equals(session.getSessionStatus())) {
            log.warn("文档 {} 上次保存失败,本次降级只读", session.getFileId());
            return MODE_VIEW;
        }
        if (isLockedByOthers(session, user.getUserId())) {
            log.info("文档 {} 正被 {} 编辑,{} 降级只读", session.getFileId(), session.getLockUserId(), user.getUserId());
            return MODE_VIEW;
        }
        return MODE_EDIT;
    }
 
    /**
     * 菜单/按钮级编辑权判定。权限码在登录时已灌进 Sa-Token 会话,此处纯本地判断、无远程调用。
     *
     * <p>判不了的时候取哪一边由 {@code edit-permission.strict} 决定:默认放行(保持里程碑 3
     * 之前的行为,那时最简调用并不传 bizModule),登记齐全后应当打开严格模式——否则
     * 「漏登记」会表现为悄悄放行编辑,而不是能被人发现的报错。
     */
    private boolean hasEditPermission(String bizModule, UserInfo user) {
        // 超管直通,与 lims 既有判定同一口径(LimsJianceTaskServiceImpl#fetchAuditorOrganizes)
        if (Boolean.TRUE.equals(user.getIsAdministrator())) {
            return true;
        }
 
        OnlyOfficeProperties.EditPermission rule = properties.getEditPermission();
        OnlyOfficeProperties.ModuleMapping mapping = ObjectUtils.isEmpty(bizModule)
                ? null
                : rule.getModules().stream()
                        .filter(item -> bizModule.equals(item.getBizModule()))
                        .findFirst()
                        .orElse(null);
 
        if (mapping == null || ObjectUtils.isEmpty(mapping.getModuleId())) {
            if (rule.isStrict()) {
                log.info("bizModule={} 未登记到 onlyoffice.edit-permission.modules,严格模式下降级只读", bizModule);
                return false;
            }
            log.warn("bizModule={} 未登记编辑权限映射,当前为宽松模式故放行编辑;登记齐全后请开启 edit-permission.strict", bizModule);
            return true;
        }
 
        String moduleId = mapping.getModuleId();
        // 菜单级:连功能本身都没授权就没有继续判断的必要
        if (!StpUtil.hasPermission(moduleId)) {
            log.info("用户 {} 无功能 {} 的菜单权限,文档降级只读", user.getUserId(), moduleId);
            return false;
        }
        // 按钮级:仅当该表单启用了按钮权限时才有意义,否则没人会有这个权限码(见 ModuleMapping 注释)
        if (mapping.isRequireBtnEdit() && !StpUtil.hasPermission(moduleId + "::" + PermissionConst.BTN_EDIT)) {
            log.info("用户 {} 无功能 {} 的编辑按钮权限,文档降级只读", user.getUserId(), moduleId);
            return false;
        }
        return true;
    }
 
    /**
     * 锁是否被他人持有。锁正常由 status 2/4 回调释放;浏览器崩溃等异常场景下
     * 靠心跳超时兜底,否则文档会永久锁死。
     */
    private boolean isLockedByOthers(OnlyOfficeSessionEntity session, String currentUserId) {
        String holder = session.getLockUserId();
        if (ObjectUtils.isEmpty(holder) || holder.equals(currentUserId)) {
            return false;
        }
        Date heartbeat = session.getLockHeartbeat();
        if (heartbeat == null) {
            return false;
        }
        long timeoutMillis = properties.getLockTimeoutMinutes() * 60L * 1000L;
        return System.currentTimeMillis() - heartbeat.getTime() < timeoutMillis;
    }
 
    private void acquireLock(OnlyOfficeSessionEntity session, String userId) {
        session.setLockUserId(userId);
        session.setLockHeartbeat(new Date());
        session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING);
        sessionMapper.updateById(session);
    }
 
    // ── config 组装 ─────────────────────────────────────────────────────────
 
    private Map<String, Object> assembleConfig(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session,
                                               String documentType, String extension, String mode, UserInfo user,
                                               List<OnlyOfficePluginResolver.ResolvedPlugin> resolvedPlugins) {
        boolean editable = MODE_EDIT.equals(mode);
        boolean hideComments = !editable || ELN_TEMPLATE_ANNOTATE_SCENE.equals(param.getBizScene());
 
        Map<String, Object> permissions = new LinkedHashMap<>();
        permissions.put("edit", editable);
        // 只读模式关闭协作菜单;ELN 标注场景也隐藏批注,避免批注层与模板字段标注混在一起。
        // 仅设 permissions.comment=false 关不干净,必须配上 customization.comments=false。
        // DS 把「能写批注」和「能看批注面板」拆成了两个标志,且后者压根不看 permissions
        // (web-apps/apps/documenteditor/main/app/controller/Main.js:1725-1727):
        //   canComments     = permissions.comment && mode!=='view' && customization.comments!==false
        //   canViewComments = canComments || customization.comments!==false   ← 这里没有 permissions
        // 而左栏那个批注按钮的显隐取的是 canViewComments(controller/LeftMenu.js:206)。
        // 所以只关 permissions.comment 的效果是「按钮还在、点开只能看不能写」。2026-08-08 实踩。
        if (hideComments) {
            permissions.put("comment", false);
            // 模板标注会用内容控件替换选区;禁用修订,避免原字符被记录为删除、新控件被记录为新增。
            permissions.put("review", false);
        }
        permissions.put("chat", false);
        // 下载与打印默认关闭:去掉「文件 → 下载为」「文件 → 打印」以及顶栏右侧那两个图标。
        //
        // ⚠️ 这两项只是**收掉界面入口**,不等于文件拿不到:/api/file/Image/** 在网关白名单内、
        // 只靠 securityKey 票据鉴权,知道 URL 的人照样能把原文件拉走。真正的下载管控必须落在
        // 文件服务那一层,别把这里的开关当安全边界。
        // 将来要按人区分,用 §6.1 那套 bizModule→moduleId 登记表判 moduleId::btn_download /
        // btn_batchPrint 即可(PermissionConst 里已有这两个码),机制现成不用新造。
        permissions.put("download", properties.isAllowDownload());
        permissions.put("print", properties.isAllowPrint());
        // 收掉「保护」:同时去掉顶栏的「保护」页签与「文件」左菜单里的「保护」按钮。
        // 归 permissions.protect 管而不是 customization——customization.layout.toolbar.protect
        // 属白标参数,社区版镜像不认。DS ≥ 7.5 支持本项(当前 9.4.0.1)。
        //
        // 关掉的理由是它与本集成的模型冲突:加密/只读保护写进的是**文档自身**,
        // 而我们编辑完是原地覆盖回同一个 fileId,一旦被加密,下次打开 DS 要密码、
        // 后端也无从解,等于把附件锁死。谁能编辑由 §6.1 的权限判定 + 编辑锁决定。
        permissions.put("protect", false);
 
        Map<String, Object> document = new LinkedHashMap<>();
        document.put("fileType", extension);
        document.put("key", session.getDocKey());
        document.put("title", param.getFileName());
        document.put("url", buildFileFetchUrl(param, user));
        document.put("permissions", permissions);
 
        Map<String, Object> owner = new LinkedHashMap<>();
        owner.put("id", user.getUserId());
        owner.put("name", user.getUserName());
 
        Map<String, Object> customization = buildCustomization(editable, !resolvedPlugins.isEmpty(), hideComments);
 
        // 协同不在本期范围,但 DS 没有关闭协同的开关(coEditing 只有 fast/strict)。
        // strict + change:false 让行为至少可预期:改动要保存后才互相可见,且禁掉界面切换入口
        // (用户在界面改过模式会写进 localStorage 并覆盖此处传值)。真正的隔离靠编辑锁。
        Map<String, Object> coEditing = new LinkedHashMap<>();
        coEditing.put("mode", "strict");
        coEditing.put("change", false);
 
        Map<String, Object> editorConfig = new LinkedHashMap<>();
        editorConfig.put("callbackUrl", properties.getCallbackBaseUrl() + "/api/biz/onlyoffice/callback");
        editorConfig.put("lang", "zh");
        editorConfig.put("mode", mode);
        editorConfig.put("user", owner);
        editorConfig.put("coEditing", coEditing);
        editorConfig.put("customization", customization);
        if (!resolvedPlugins.isEmpty()) {
            Map<String, Object> plugins = new LinkedHashMap<>();
            List<String> pluginsData = new ArrayList<>();
            List<String> autostart = new ArrayList<>();
            Map<String, Object> options = new LinkedHashMap<>();
            for (OnlyOfficePluginResolver.ResolvedPlugin resolvedPlugin : resolvedPlugins) {
                pluginsData.add(resolvedPlugin.getManifestUrl());
                if (resolvedPlugin.isAutostart()) {
                    autostart.add(resolvedPlugin.getGuid());
                }
                Map<String, Object> context = resolvedPlugin.runtimeContext(param.getFileId(), param.getBizDataId());
                if (OnlyOfficeFillTicketIssuer.PLUGIN_CODE.equals(resolvedPlugin.getPluginCode())) {
                    context.put("canFill", editable);
                    context.put("scope", OnlyOfficeFillTicketIssuer.SCOPE);
                    context.put("ticket", fillTicketIssuer.issue(param, user, resolvedPlugin.getGuid()));
                } else if (ELN_TEMPLATE_ANNOTATE_SCENE.equals(param.getBizScene())) {
                    context.put("canAnnotate", editable);
                }
                options.put(resolvedPlugin.getGuid(), context);
            }
            plugins.put("pluginsData", pluginsData);
            plugins.put("options", options);
            if (!autostart.isEmpty()) {
                plugins.put("autostart", autostart);
            }
            List<String> disabledPluginGuids = resolvedPlugins.get(0).getDisabledPluginGuids();
            if (!disabledPluginGuids.isEmpty()) {
                plugins.put("disable", disabledPluginGuids);
            }
            editorConfig.put("plugins", plugins);
        }
 
        Map<String, Object> config = new LinkedHashMap<>();
        config.put("document", document);
        config.put("documentType", documentType);
        config.put("editorConfig", editorConfig);
 
        // token 的 payload 就是上面这份 config 本身;DS 收到后用同一密钥验签,
        // 防止浏览器篡改 permissions / callbackUrl。必须最后放,不能把自己算进去。
        config.put("token", JWTUtil.createToken(config, requireJwtSecret().getBytes(StandardCharsets.UTF_8)));
        return config;
    }
 
    /**
     * 编辑器界面裁剪。**必须在后端拼进 config**:customization 在 JWT 签名范围内,
     * 前端往 config 里塞字段会让 DS 验签失败(前端只能追加 events,函数不参与序列化)。
     *
     * <p>两类考虑分开看:
     * <ul>
     *   <li><b>安全</b>:{@code macros}/{@code plugins}。附件是用户上传的外来文档,
     *       可能带宏;插件(含 AI 入口)会把文档内容发往第三方,都不该默认开着。</li>
     *   <li><b>界面噪音</b>:帮助、反馈、顶栏文件名(弹窗标题已经有了)、右侧面板。
     *       这些纯属观感,改动无风险。</li>
     * </ul>
     *
     * <p>⚠️ logo / about / layout 那一类属**白标定制**,社区版镜像大概率不生效,故此处不设——
     * 需要的话得先实测再加,别写了以为生效。
     *
     * @param editable 是否可编辑;forcesave/autosave 只在编辑态有意义,顶栏展开与否也随它变
     */
    private Map<String, Object> buildCustomization(boolean editable, boolean pluginsEnabled, boolean hideComments) {
        Map<String, Object> customization = new LinkedHashMap<>();
 
        // ── 安全 ──
        // 外来 docx 可能带宏:不自动运行,且不给用户开的入口
        customization.put("macros", false);
        customization.put("macrosMode", "disable");
        // 仅后端白名单场景开启指定插件;空场景仍关闭全部插件入口。
        customization.put("plugins", pluginsEnabled);
 
        // ── 界面收敛 ──
        // 默认关闭拼写检查;用户在界面修改后,localStorage 仍会覆盖这个初始值。
        Map<String, Object> features = new LinkedHashMap<>();
        features.put("spellcheck", false);
        customization.put("features", features);
        if (hideComments) {
            // 批注面板的另一半开关,与 permissions.comment=false 必须成对出现。
            customization.put("comments", false);
            Map<String, Object> review = new LinkedHashMap<>();
            review.put("trackChanges", false);
            review.put("showReviewChanges", false);
            customization.put("review", review);
        }
        customization.put("help", false);
        customization.put("feedback", false);
        // 左下角那个「提出功能建议」归 suggestFeature 管,不是 feedback(实测得知)
        customization.put("suggestFeature", false);
        // 顶栏不再重复显示文件名——我们的弹窗标题已经有了
        customization.put("toolbarHideFileName", true);
        customization.put("hideRightMenu", true);
        // 顶栏形态按模式分:编辑态展开成完整功能区(工具找得到),只读态收成单行紧凑版
        // (没有可用的编辑按钮,展开只是白占垂直空间)。这与 DS 自己的默认取向一致,
        // 但仍显式写出——默认值随版本变过,别指望省略。
        //
        // ⚠️ 本项会被浏览器 localStorage 覆盖:用户手动折叠过顶栏,之后打开就一直是折叠的,
        // 后端传什么都不算数。验证时请换无痕窗口,别以为配置没生效。
        customization.put("compactToolbar", !editable);
 
        customization.put("autosave", false);
        if (editable) {
            // forcesave:点保存即触发 status 6 回调,不必等全员退出才落盘
            customization.put("forcesave", true);
        }
        return customization;
    }
 
    /**
     * DS 回源拉文档的地址。走 /api/file/Image/**——该路径在 GatewayWhite 硬编码白名单内,
     * DS 没有登录态也能取,鉴权靠 URL 上的 securityKey 票据。
     *
     * <p>🔴 {@code &t=t} 不能省,且它与"时间戳"无关。{@code FileServiceImpl#flushFile} 里
     * {@code redirect = StringUtil.isEmpty(t)}:**带 securityKey 但不带 t 时,该端点不吐文件流,
     * 而是 302 跳到 ApiDomain 上的预览地址**。DS 会跟着跳,最终拿到
     * {@code {"code":400,"msg":"System abnormality"}} 并把这段 JSON 存成 origin.docx,
     * 表现为编辑器一直转圈(HTTP 200 让 DS 以为下载成功了)。前端的
     * {@code getAuthMediaUrl(url, isRedirect=false)} 也是靠追加 {@code &t=t} 拿原始流的,此处同源。
     * 2026-08-08 实踩。
     */
    private String buildFileFetchUrl(OnlyOfficeConfigParam param, UserInfo user) {
        return properties.getFileFetchBaseUrl()
                + "/api/file/Image/" + param.getFileType() + "/" + param.getFileId()
                + "?s=" + user.getSecurityKey() + "&t=t";
    }
 
    // ── 工具 ────────────────────────────────────────────────────────────────
 
    /**
     * 取签名密钥,顺带挡住「占位符没被解析」这种静默失败。
     *
     * <p>配置写的是 {@code ${ONLYOFFICE_JWT_SECRET}},而 **Spring Boot 的 Binder 对解析不了的
     * 占位符是原样放行**(不抛异常)。所以变量没透传进容器时,这里拿到的是字面量
     * {@code "${ONLYOFFICE_JWT_SECRET}"}——非空、能过空值校验、能签出 token,但 DS 验签必然
     * 失败,表现为编辑器里「文档安全令牌的格式不正确」,且后端毫无异常可查。
     * 2026-08-08 实踩过一次(compose 只把该变量喂给了 cx-onlyoffice,漏了 cx-biz-common)。
     */
    private String requireJwtSecret() {
        String secret = properties.getJwtSecret();
        if (ObjectUtils.isEmpty(secret)) {
            throw new DataException("OnlyOffice 未配置 jwt-secret,无法签发/校验令牌");
        }
        if (secret.startsWith("${")) {
            throw new DataException("OnlyOffice 的 jwt-secret 仍是未解析的占位符(" + secret
                    + "),说明运行环境没有注入 ONLYOFFICE_JWT_SECRET——容器栈检查 docker-compose.yml"
                    + " 的 cx-biz-common environment,宿主栈检查 .env 是否被 start-all.sh 加载");
        }
        return secret;
    }
 
    /**
     * doc_key:DS 用它判断「是不是同一份文档的同一个版本」。
     * md5 取 32 位十六进制,天然满足 DS 对字符集 [0-9a-zA-Z_-] 与长度 ≤128 的要求。
     */
    private String buildDocKey(String fileId, int versionNo) {
        return DigestUtil.md5Hex(fileId + "_" + versionNo);
    }
 
    private String extensionOf(String fileName) {
        int dot = fileName.lastIndexOf('.');
        if (dot < 0 || dot == fileName.length() - 1) {
            throw new DataException("无法识别文件扩展名:" + fileName);
        }
        return fileName.substring(dot + 1).toLowerCase();
    }
}