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 的签发。 * *

本类只负责「打开文档」这一段。三段链路的凭据各不相同:打开走用户登录 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 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 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 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 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 getSaveStatus(String fileId) { if (ObjectUtils.isEmpty(fileId)) { throw new DataException("fileId 不能为空"); } OnlyOfficeSessionEntity session = sessionMapper.selectOne(Wrappers.lambdaQuery() .eq(OnlyOfficeSessionEntity::getFileId, fileId) .last("limit 1")); Map 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.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 产出的新版文档写回文件存储。 * *

原地覆盖同一个 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.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 会话,此处纯本地判断、无远程调用。 * *

判不了的时候取哪一边由 {@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 assembleConfig(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session, String documentType, String extension, String mode, UserInfo user, List resolvedPlugins) { boolean editable = MODE_EDIT.equals(mode); boolean hideComments = !editable || ELN_TEMPLATE_ANNOTATE_SCENE.equals(param.getBizScene()); Map 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 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 owner = new LinkedHashMap<>(); owner.put("id", user.getUserId()); owner.put("name", user.getUserName()); Map customization = buildCustomization(editable, !resolvedPlugins.isEmpty(), hideComments); // 协同不在本期范围,但 DS 没有关闭协同的开关(coEditing 只有 fast/strict)。 // strict + change:false 让行为至少可预期:改动要保存后才互相可见,且禁掉界面切换入口 // (用户在界面改过模式会写进 localStorage 并覆盖此处传值)。真正的隔离靠编辑锁。 Map coEditing = new LinkedHashMap<>(); coEditing.put("mode", "strict"); coEditing.put("change", false); Map 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 plugins = new LinkedHashMap<>(); List pluginsData = new ArrayList<>(); List autostart = new ArrayList<>(); Map options = new LinkedHashMap<>(); for (OnlyOfficePluginResolver.ResolvedPlugin resolvedPlugin : resolvedPlugins) { pluginsData.add(resolvedPlugin.getManifestUrl()); if (resolvedPlugin.isAutostart()) { autostart.add(resolvedPlugin.getGuid()); } Map 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 disabledPluginGuids = resolvedPlugins.get(0).getDisabledPluginGuids(); if (!disabledPluginGuids.isEmpty()) { plugins.put("disable", disabledPluginGuids); } editorConfig.put("plugins", plugins); } Map 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,函数不参与序列化)。 * *

两类考虑分开看: *

* *

⚠️ logo / about / layout 那一类属**白标定制**,社区版镜像大概率不生效,故此处不设—— * 需要的话得先实测再加,别写了以为生效。 * * @param editable 是否可编辑;forcesave/autosave 只在编辑态有意义,顶栏展开与否也随它变 */ private Map buildCustomization(boolean editable, boolean pluginsEnabled, boolean hideComments) { Map customization = new LinkedHashMap<>(); // ── 安全 ── // 外来 docx 可能带宏:不自动运行,且不给用户开的入口 customization.put("macros", false); customization.put("macrosMode", "disable"); // 仅后端白名单场景开启指定插件;空场景仍关闭全部插件入口。 customization.put("plugins", pluginsEnabled); // ── 界面收敛 ── // 默认关闭拼写检查;用户在界面修改后,localStorage 仍会覆盖这个初始值。 Map features = new LinkedHashMap<>(); features.put("spellcheck", false); customization.put("features", features); if (hideComments) { // 批注面板的另一半开关,与 permissions.comment=false 必须成对出现。 customization.put("comments", false); Map 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 票据。 * *

🔴 {@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"; } // ── 工具 ──────────────────────────────────────────────────────────────── /** * 取签名密钥,顺带挡住「占位符没被解析」这种静默失败。 * *

配置写的是 {@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(); } }