package jnpf.bizcommon.onlyoffice.service.impl; import cn.hutool.core.util.IdUtil; import cn.hutool.core.net.URLEncodeUtil; 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.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import jnpf.base.ActionResult; import jnpf.base.ActionResultCode; 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.GlobalConst; import jnpf.eln.TemplateEditabilityApi; import jnpf.eln.RecordFillApi; import jnpf.eln.RecordFillSaveCommand; import jnpf.eln.RecordFillSaveOperation; import jnpf.exception.DataException; import jnpf.file.FileApi; import jnpf.file.FileUploadApi; import jnpf.model.upload.UploadFileModel; import feign.Response; import jnpf.util.JsonUtil; import jnpf.util.UserProvider; import jnpf.util.UploaderUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.dromara.x.file.storage.core.FileInfo; import org.springframework.stereotype.Service; import org.springframework.dao.DuplicateKeyException; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; 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.Objects; import java.util.Set; /** * 编辑器 config 的签发。 * *

本类只负责「打开文档」这一段。三段链路的凭据各不相同:打开走用户登录 token, * DS 回源走 securityKey 票据,DS 回调走本类签发的同一把 JWT 密钥。 */ @Slf4j @Service @RequiredArgsConstructor public class OnlyOfficeServiceImpl implements OnlyOfficeService { @org.springframework.beans.factory.annotation.Autowired private UploadEditSessionSupport uploadEditSessionSupport; private final OnlyOfficeProperties properties; private final OnlyOfficeSessionMapper sessionMapper; private final OnlyOfficePluginResolver pluginResolver; private final OnlyOfficeSceneConfigRegistry sceneConfigRegistry; private final TemplateEditabilityApi templateEditabilityApi; private final RecordFillApi recordFillApi; private final DmsOnlyOfficePreviewTicketValidator dmsPreviewTicketValidator; private final FileApi fileApi; private final FileUploadApi fileUploadApi; private final DmsQianpiyeDocxPropertiesWriter qianpiyeDocxPropertiesWriter; /** 扩展名 → 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 MODE_QIANPIYE = "qianpiye"; private static final String SCENE_DMS_QIANPIYE = "dms.qianpiye"; private static final class LockCasConflictException extends DataException { private LockCasConflictException() { super("文档编辑锁获取失败,请重新打开"); } } @Override public Map buildEditorConfig(OnlyOfficeConfigParam param) { if (ObjectUtils.isEmpty(param.getFileId()) || ObjectUtils.isEmpty(param.getFileName())) { throw new DataException("fileId 与 fileName 不能为空"); } if (StringUtils.hasText(param.getLocateControlTag()) && StringUtils.hasText(param.getRepairBindingId())) { throw new DataException("locateControlTag 与 repairBindingId 不能同时使用"); } requireJwtSecret(); UserInfo user = UserProvider.getUser(); boolean dmsPrint = false; if (isDms(param)) { dmsPrint = jnpf.dmsEntity.preview.model.DmsFilePreviewTicket.PRINT_SCOPE.equals( dmsPreviewTicketValidator.validate(param, user).getScope()); } if (param.getMode() == null || !java.util.Set.of("edit", "view", "qianpiye", "review", "viewComments").contains(param.getMode())) throw new DataException("不支持的文档打开模式"); String extension = extensionOf(param.getFileName()); if (("review".equals(param.getMode()) || "viewComments".equals(param.getMode())) && !"docx".equals(extension)) throw new DataException("审批和批注可见预览目前仅支持DOCX"); String documentType = DOCUMENT_TYPES.get(extension); if (documentType == null) { throw new DataException("OnlyOffice 不支持的文件格式:" + extension); } List resolvedPlugins = pluginResolver.resolve(param); String tenantId = tenantIdOf(user); OnlyOfficeSceneConfigBuilder sceneBuilder = sceneConfigRegistry.resolve(param); OnlyOfficeSessionEntity session = loadOrCreateSession(param, documentType, tenantId, sceneBuilder); OnlyOfficeSceneBuildPlan scenePlan = sceneBuilder.build( new OnlyOfficeSceneBuildContext(param, session, user, tenantId)); String mode = resolveMode(param, extension, session, user, scenePlan); Integer acquiredLockVersion = null; if (MODE_EDIT.equals(mode)) { try { acquiredLockVersion = acquireLock(session, user.getUserId()); } catch (LockCasConflictException e) { OnlyOfficeSessionEntity fenced = reloadFencedSession(param, tenantId, sceneBuilder); if (fenced == null) { throw e; } session = fenced; mode = MODE_VIEW; } } try { if (MODE_EDIT.equals(mode) && !"review".equals(param.getMode()) && !scenePlan.reviewOnly() && scenePlan.uploadQianpiyeAnnotatorEnabled() && ("doc".equals(extension) || "docx".equals(extension))) { resolvedPlugins = pluginResolver.resolveUploadQianpiyeAnnotator(); } return assembleConfig(param, session, documentType, extension, mode, user, resolvedPlugins, scenePlan, dmsPrint); } catch (RuntimeException e) { if (acquiredLockVersion != null) { try { releaseLockIfOwned(session, user.getUserId(), acquiredLockVersion); } catch (RuntimeException releaseError) { log.error("OnlyOffice config 组装失败后释放编辑锁失败,file={}", session.getFileId(), releaseError); } } throw e; } } @Override public Map getSaveStatus(String fileId, String fileType) { if (ObjectUtils.isEmpty(fileId)) { throw new DataException("fileId 不能为空"); } if (ObjectUtils.isEmpty(fileType)) { throw new DataException("fileType 不能为空"); } String tenantId = tenantIdOf(UserProvider.getUser()); OnlyOfficeSessionEntity session = sessionMapper.selectOne(Wrappers.lambdaQuery() .eq(OnlyOfficeSessionEntity::getTenantId, tenantId) .eq(OnlyOfficeSessionEntity::getFileId, fileId) .eq(OnlyOfficeSessionEntity::getFileType, fileType) .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()); status.put("sessionStatus", session == null ? "IDLE" : sessionStatusName(session.getSessionStatus())); if (isQianpiyeSession(session)) { Map savedFile = qianpiyeSavedFile(session); if (savedFile != null) { status.put("file", savedFile); } } if (UploadEditSessionSupport.matches(session)) { // 上传控件关闭后通过 load 接口刷新工作附件;轮询不返回文件,避免每次读取完整主子表。 uploadEditSessionSupport.requireStatusAccess(tenantId, UserProvider.getUser().getUserId(), fileId); } return status; } private String sessionStatusName(Integer sessionStatus) { if (sessionStatus == null) { return "UNKNOWN"; } switch (sessionStatus) { case OnlyOfficeSessionEntity.SessionStatus.IDLE: return "IDLE"; case OnlyOfficeSessionEntity.SessionStatus.EDITING: return "EDITING"; case OnlyOfficeSessionEntity.SessionStatus.SAVING: return "SAVING"; case OnlyOfficeSessionEntity.SessionStatus.SAVE_FAILED: return "SAVE_FAILED"; case OnlyOfficeSessionEntity.SessionStatus.PUBLISHING: return "PUBLISHING"; case OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING: return "RECORD_RETIRING"; case OnlyOfficeSessionEntity.SessionStatus.RECORD_HISTORICAL: return "RECORD_HISTORICAL"; default: return "UNKNOWN"; } } // ── 保存回调 ──────────────────────────────────────────────────────────── @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()); } if ("dms".equalsIgnoreCase(session.getFileType())) { log.debug("忽略 DMS 只读预览回调,file={} key={} status={}", session.getFileId(), param.getKey(), param.getStatus()); return; } if (UploadEditSessionSupport.matches(session)) { try { UploadEditSessionSupport.requireBoundCallback(param, authorization); if (param.getStatus() == OnlyOfficeCallbackParam.Status.READY_FOR_SAVING || param.getStatus() == OnlyOfficeCallbackParam.Status.FORCE_SAVING) { saveBack(session, param, param.getStatus() == OnlyOfficeCallbackParam.Status.READY_FOR_SAVING); return; } } catch (Exception e) { throw new jnpf.bizcommon.onlyoffice.exception.UploadEditCallbackException(e); } } 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: log.error("OnlyOffice 保存出错,file={} key={} status={}", session.getFileId(), param.getKey(), param.getStatus()); releaseLock(session, OnlyOfficeSessionEntity.SessionStatus.SAVE_FAILED); break; case OnlyOfficeCallbackParam.Status.FORCE_SAVE_ERROR: // status 7 只是强制保存失败,编辑会话仍在继续,不能释放其编辑锁。 log.error("OnlyOffice 强制保存出错,保持当前编辑租约,file={} key={}", session.getFileId(), param.getKey()); 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。签批页是例外:它将 DS 产物上传为新的通用附件, * 保留原附件内容,并把新的上传组件 fileItem 经保存状态接口交给前端 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"); } if (!claimSave(session)) { if (isLeaseSaving(session)) { throw new DataException("OnlyOffice 同一租约保存回调正在处理"); } log.info("忽略已失效 OnlyOffice 保存回调,file={} key={}", session.getFileId(), session.getDocKey()); return; } String downloadUrl = rewriteToInternalHost(param.getUrl()); byte[] original = null; boolean recordDocument = ElnRecordFillTicketIssuer.BIZ_MODULE.equals(session.getBizModule()); boolean candidateWritten = false; boolean preserveSaving = false; RecordFillSaveCommand recordCommand = null; Map qianpiyeSavedFile = null; try (HttpResponse response = HttpUtil.createGet(downloadUrl).timeout(60_000).executeAsync()) { if (!response.isOk()) { throw new DataException("从 DS 下载编辑后文档失败,HTTP " + response.getStatus()); } byte[] candidate = response.bodyBytes(); if (isQianpiyeSession(session)) { candidate = qianpiyeDocxPropertiesWriter.upsertStagedFlowVersions(candidate); } String folderPath = fileApi.getPath(session.getFileType()); if (ObjectUtils.isEmpty(folderPath)) { throw new DataException("文件服务无法解析文档存储目录"); } if (!folderPath.endsWith("/") && !folderPath.endsWith("\\")) { folderPath += "/"; } if (recordDocument) { original = readCurrentFile(session.getFileType(), session.getFileId()); recordCommand = recordSaveCommand(session, param, rotateKey); RecordFillSaveOperation prepared = requireRecordOperation(recordFillApi.preflight(recordCommand)); if (!RecordFillSaveOperation.PREPARED.equals(prepared.getStatus())) { throw new DataException(prepared.getReason()); } recordCommand.setOperationCode(prepared.getOperationCode()); } if (UploadEditSessionSupport.matches(session)) { uploadEditSessionSupport.save(session, param, candidate); } else if (isQianpiyeSession(session)) { qianpiyeSavedFile = saveQianpiyeCopy(session, candidate, folderPath); } else if (!fileUploadApi.replaceFile(new InMemoryMultipartFile(session.getFileId(), candidate), folderPath, session.getFileId())) { throw new DataException("文件服务写回文档失败"); } candidateWritten = recordDocument; if (recordDocument) { RecordFillSaveOperation outcome; try { outcome = requireRecordOperation(recordFillApi.commit(recordCommand)); } catch (RuntimeException responseLost) { outcome = recordSaveStatus(recordCommand); } if (outcome != null && RecordFillSaveOperation.COMMITTED.equals(outcome.getStatus())) { // The ELN transaction is authoritative. Complete the session only after it commits. } else if (outcome != null && RecordFillSaveOperation.REJECTED.equals(outcome.getStatus())) { try { restoreRecordFile(folderPath, session, original); } catch (DataException restoreFailure) { preserveSaving = true; throw restoreFailure; } throw new DataException(StringUtils.hasText(outcome.getReason()) ? outcome.getReason() : "ELN 电子记录保存被拒绝"); } else { preserveSaving = true; throw new DataException("ELN 保存结果不确定,已保留文档和保存租约,请人工核对"); } } } catch (DataException e) { if (recordDocument && recordCommand != null && StringUtils.hasText(recordCommand.getOperationCode()) && !candidateWritten && !preserveSaving) { abortRecordSave(recordCommand); } if (!recordDocument || !candidateWritten || !preserveSaving) { restoreEditingAfterSaveFailure(session); } throw e; } catch (Exception e) { // Feign 传输异常无法区分「ELN 已提交但响应丢失」;保留候选文档和 SAVING 供状态核对, // 绝不能盲目恢复旧文件覆盖已提交的结构化值。 if (recordDocument && candidateWritten) { throw new DataException("ELN 保存结果不确定,已保留文档和保存租约,请人工核对"); } restoreEditingAfterSaveFailure(session); throw new DataException("写回文档失败:" + e.getMessage()); } int nextVersion = session.getVersionNo() == null ? 1 : session.getVersionNo() + 1; Date lastSaveTime = new Date(); String lastEditorIds = param.getUsers() == null ? null : String.join(",", param.getUsers()); UpdateWrapper complete = leaseUpdate(session, OnlyOfficeSessionEntity.SessionStatus.SAVING) .set("last_save_time", lastSaveTime) .set("last_editor_ids", lastEditorIds); if (qianpiyeSavedFile != null) { complete.set("qianpiye_saved_file_json", JsonUtil.getObjectToString(qianpiyeSavedFile)); } if (rotateKey) { // 内容变了必须换 key,否则下次打开 DS 会命中自身缓存返回旧内容 complete.set("version_no", nextVersion) .set("doc_key", buildDocKey(session.getFileId(), nextVersion)) .set("lock_user_id", null) .set("lock_heartbeat", null) .set("session_status", OnlyOfficeSessionEntity.SessionStatus.IDLE); } else { complete.set("session_status", OnlyOfficeSessionEntity.SessionStatus.EDITING); } if (sessionMapper.update(null, complete) != 1) { // 文件存储与会话表不在同一事务内。保留 SAVING,避免旧租约伪造保存成功; // 不得自动接管,需人工确认文件内容并修复会话。 log.error("OnlyOffice 文件已写回但会话完成 CAS 失败,需人工确认,file={} key={}", session.getFileId(), session.getDocKey()); throw new DataException("文件已写回,但 OnlyOffice 会话状态更新失败,请人工确认后重试"); } session.setLastSaveTime(lastSaveTime); session.setLastEditorIds(lastEditorIds); if (qianpiyeSavedFile != null) { session.setQianpiyeSavedFileJson(JsonUtil.getObjectToString(qianpiyeSavedFile)); } if (rotateKey) { session.setVersionNo(nextVersion); session.setDocKey(buildDocKey(session.getFileId(), nextVersion)); session.setLockUserId(null); session.setLockHeartbeat(null); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); } else { session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); } log.info("OnlyOffice 保存回写完成,file={} version={} rotateKey={}", session.getFileId(), session.getVersionNo(), rotateKey); } private Map saveQianpiyeCopy(OnlyOfficeSessionEntity session, byte[] candidate, String folderPath) { Map savedFile = qianpiyeSavedFile(session); if (savedFile != null) { String savedFileId = String.valueOf(savedFile.get("fileId")); if (!fileUploadApi.replaceFile(new InMemoryMultipartFile(savedFileId, candidate), folderPath, savedFileId)) { throw new DataException("文件服务写回签批页另存文档失败"); } // 同一编辑租约内始终覆盖第一次另存出的文件,fileItem 的 fileId 与 url 保持不变。 savedFile.put("fileSize", candidate.length); return savedFile; } FileInfo stored = fileUploadApi.uploadFile( new InMemoryMultipartFile(session.getFileName(), candidate), folderPath, session.getFileName()); if (stored == null || !StringUtils.hasText(stored.getFilename())) { throw new DataException("文件服务另存签批页文档失败"); } Map file = new LinkedHashMap<>(); file.put("fileId", stored.getFilename()); file.put("name", session.getFileName()); file.put("fileSize", stored.getSize()); file.put("fileExtension", extensionOf(session.getFileName())); file.put("url", UploaderUtil.uploaderFile(stored.getFilename() + "#" + session.getFileType()) + "&name=" + URLEncodeUtil.encode(session.getFileName(), GlobalConst.DEFAULT_CHARSET)); return file; } @SuppressWarnings("unchecked") private Map qianpiyeSavedFile(OnlyOfficeSessionEntity session) { if (session == null || !StringUtils.hasText(session.getQianpiyeSavedFileJson())) { return null; } try { Map file = JsonUtil.stringToMap(session.getQianpiyeSavedFileJson()); Object fileId = file == null ? null : file.get("fileId"); return fileId != null && StringUtils.hasText(String.valueOf(fileId)) ? file : null; } catch (Exception e) { log.warn("签批页保存结果解析失败,file={}", session.getFileId(), e); return null; } } private boolean isQianpiyeSession(OnlyOfficeSessionEntity session) { return session != null && SCENE_DMS_QIANPIYE.equals(session.getBizScene()); } private RecordFillSaveCommand recordSaveCommand(OnlyOfficeSessionEntity session, OnlyOfficeCallbackParam param, boolean finalSave) { RecordFillSaveCommand command = new RecordFillSaveCommand(); command.setTenantId(session.getTenantId()); command.setUserId(session.getLockUserId()); command.setRecordId(session.getBizDataId()); command.setFileId(session.getFileId()); command.setSessionDocKey(session.getDocKey()); command.setSessionVersion(session.getVersionNo()); command.setCallbackStatus(param.getStatus()); command.setFinalSave(finalSave); return command; } private RecordFillSaveOperation requireRecordOperation(ActionResult result) { if (result == null || !ActionResultCode.SUCCESS.getCode().equals(result.getCode()) || result.getData() == null) { throw new DataException(result != null && StringUtils.hasText(result.getMsg()) ? result.getMsg() : "ELN 电子记录保存请求失败"); } return result.getData(); } private RecordFillSaveOperation recordSaveStatus(RecordFillSaveCommand command) { try { return requireRecordOperation(recordFillApi.saveStatus(command.getTenantId(), command.getOperationCode())); } catch (RuntimeException ignored) { return null; } } private void abortRecordSave(RecordFillSaveCommand command) { try { requireRecordOperation(recordFillApi.abort(command)); } catch (RuntimeException e) { log.error("ELN PREPARED 保存操作无法中止,operation={}", command.getOperationCode(), e); } } private byte[] readCurrentFile(String fileType, String fileId) { try (Response response = fileUploadApi.downFile(new UploadFileModel(fileType, fileId))) { if (response == null || response.status() < 200 || response.status() >= 300 || response.body() == null) { throw new DataException("电子记录原文档备份失败"); } try (InputStream input = response.body().asInputStream(); java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream()) { byte[] buffer = new byte[8192]; int count; while ((count = input.read(buffer)) != -1) { output.write(buffer, 0, count); } if (output.size() == 0) throw new DataException("电子记录原文档为空"); return output.toByteArray(); } } catch (IOException | RuntimeException e) { throw new DataException("电子记录原文档备份失败"); } } private void restoreRecordFile(String folderPath, OnlyOfficeSessionEntity session, byte[] original) { if (original == null || !fileUploadApi.replaceFile( new InMemoryMultipartFile(session.getFileId(), original), folderPath, session.getFileId())) { throw new DataException("ELN 保存被拒绝且原文档恢复失败,请人工核对"); } } private void invalidateTemplateSession(OnlyOfficeSessionEntity session) { int nextVersion = session.getVersionNo() == null ? 1 : session.getVersionNo() + 1; String nextDocKey = buildDocKey(session.getFileId(), nextVersion); int affected = sessionMapper.update(null, leaseUpdate(session, OnlyOfficeSessionEntity.SessionStatus.EDITING) .set("version_no", nextVersion) .set("doc_key", nextDocKey) .set("lock_user_id", null) .set("lock_heartbeat", null) .set("session_status", OnlyOfficeSessionEntity.SessionStatus.IDLE)); if (affected == 1) { session.setVersionNo(nextVersion); session.setDocKey(nextDocKey); session.setLockUserId(null); session.setLockHeartbeat(null); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); } } 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) { Date heartbeat = new Date(); if (sessionMapper.update(null, leaseUpdate(session, OnlyOfficeSessionEntity.SessionStatus.EDITING) .set("lock_heartbeat", heartbeat)) == 1) { session.setLockHeartbeat(heartbeat); } } private void releaseLock(OnlyOfficeSessionEntity session, int status) { if (sessionMapper.update(null, leaseUpdate(session, OnlyOfficeSessionEntity.SessionStatus.EDITING) .set("lock_user_id", null) .set("lock_heartbeat", null) .set("session_status", status)) == 1) { session.setLockUserId(null); session.setLockHeartbeat(null); session.setSessionStatus(status); } } private boolean claimSave(OnlyOfficeSessionEntity session) { Date heartbeat = new Date(); if (sessionMapper.update(null, leaseUpdate(session, OnlyOfficeSessionEntity.SessionStatus.EDITING) .set("session_status", OnlyOfficeSessionEntity.SessionStatus.SAVING) .set("lock_heartbeat", heartbeat)) != 1) { return false; } session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.SAVING); session.setLockHeartbeat(heartbeat); return true; } private void restoreEditingAfterSaveFailure(OnlyOfficeSessionEntity session) { int affected = sessionMapper.update(null, leaseUpdate(session, OnlyOfficeSessionEntity.SessionStatus.SAVING) .set("session_status", OnlyOfficeSessionEntity.SessionStatus.EDITING)); if (affected == 1) { session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); } } private boolean isLeaseSaving(OnlyOfficeSessionEntity session) { return sessionMapper.selectOne(leaseUpdate(session, OnlyOfficeSessionEntity.SessionStatus.SAVING)) != null; } private UpdateWrapper leaseUpdate(OnlyOfficeSessionEntity session, int status) { UpdateWrapper update = Wrappers.update() .eq("f_id", session.getId()) .eq("doc_key", session.getDocKey()) .eq("version_no", session.getVersionNo()) .eq("session_status", status); if (session.getVersion() == null) { update.isNull("f_version"); } else { update.eq("f_version", session.getVersion()); } return update; } private void releaseLockIfOwned(OnlyOfficeSessionEntity session, String userId, int acquiredLockVersion) { int affected = sessionMapper.update(null, Wrappers.update() .eq("f_id", session.getId()) .eq("doc_key", session.getDocKey()) .eq("version_no", session.getVersionNo()) .eq("lock_user_id", userId) .eq("f_version", acquiredLockVersion) .eq("session_status", OnlyOfficeSessionEntity.SessionStatus.EDITING) .set("lock_user_id", null) .set("lock_heartbeat", null) .set("session_status", OnlyOfficeSessionEntity.SessionStatus.IDLE)); if (affected == 1) { session.setLockUserId(null); session.setLockHeartbeat(null); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); } } // ── 会话与编辑锁 ───────────────────────────────────────────────────────── /** * 取当前文件的会话;没有则建一条 version_no=1 的新会话。 * 空闲会话的 docKey 由 fileId + versionNo 派生;获取编辑锁后会轮换为租约专属 key。 */ private OnlyOfficeSessionEntity loadOrCreateSession(OnlyOfficeConfigParam param, String documentType, String tenantId, OnlyOfficeSceneConfigBuilder requestBuilder) { for (OnlyOfficeSceneConfigBuilder guardedBuilder : sceneConfigRegistry.guardedBuilders()) { OnlyOfficeSessionEntity guardedSession = sessionMapper.selectOne( Wrappers.lambdaQuery() .eq(OnlyOfficeSessionEntity::getTenantId, tenantId) .eq(OnlyOfficeSessionEntity::getFileId, param.getFileId()) .eq(OnlyOfficeSessionEntity::getBizModule, guardedBuilder.bizModule()) .last("limit 1")); if (guardedSession != null && guardedBuilder.bizModule().equals(guardedSession.getBizModule())) { boolean adoptUnboundSession = requestBuilder.canAdoptUnboundSession( guardedSession, param, tenantId); if (!adoptUnboundSession) { requireMatchingSession(guardedBuilder, guardedSession, param, tenantId); } return updateExistingSession(guardedSession, param, documentType, adoptUnboundSession); } } OnlyOfficeSessionEntity session = sessionMapper.selectOne(Wrappers.lambdaQuery() .eq(OnlyOfficeSessionEntity::getTenantId, tenantId) .eq(OnlyOfficeSessionEntity::getFileId, param.getFileId()) .eq(OnlyOfficeSessionEntity::getFileType, param.getFileType()) .last("limit 1")); if (session != null) { OnlyOfficeSceneConfigBuilder sessionGuard = sceneConfigRegistry.guardedBuilder(session.getBizModule()); OnlyOfficeSceneConfigBuilder guard = sessionGuard == null && requestBuilder.guardsBusinessContext() ? requestBuilder : sessionGuard; boolean adoptUnboundSession = requestBuilder.canAdoptUnboundSession(session, param, tenantId); if (guard != null && !adoptUnboundSession) { requireMatchingSession(guard, session, param, tenantId); } return updateExistingSession(session, param, documentType, adoptUnboundSession || sessionGuard == null && !requestBuilder.guardsBusinessContext()); } session = new OnlyOfficeSessionEntity(); session.setId(IdUtil.getSnowflakeNextIdStr()); session.setTenantId(tenantId); session.setFileId(param.getFileId()); session.setFileType(param.getFileType()); session.setFileName(param.getFileName()); session.setDocumentType(documentType); session.setBizModule(param.getBizModule()); session.setBizDataId(param.getBizDataId()); session.setBizScene(param.getBizScene()); session.setVersionNo(1); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); session.setDocKey(buildDocKey(param.getFileId(), 1)); session.setDeleteMark(null); try { sessionMapper.insert(session); return session; } catch (DuplicateKeyException e) { OnlyOfficeSessionEntity concurrent = sessionMapper.selectOne( Wrappers.lambdaQuery() .eq(OnlyOfficeSessionEntity::getTenantId, tenantId) .eq(OnlyOfficeSessionEntity::getFileId, param.getFileId()) .eq(OnlyOfficeSessionEntity::getFileType, param.getFileType()) .last("limit 1")); if (concurrent == null) { throw new DataException("OnlyOffice 会话并发创建失败,请重新打开"); } OnlyOfficeSceneConfigBuilder concurrentGuard = sceneConfigRegistry.guardedBuilder( concurrent.getBizModule()); boolean adoptUnboundSession = requestBuilder.canAdoptUnboundSession(concurrent, param, tenantId); if (concurrentGuard != null) { if (!adoptUnboundSession) { requireMatchingSession(concurrentGuard, concurrent, param, tenantId); } } else if (requestBuilder.guardsBusinessContext() && !adoptUnboundSession) { requireMatchingSession(requestBuilder, concurrent, param, tenantId); } return updateExistingSession(concurrent, param, documentType, adoptUnboundSession); } } private void requireMatchingSession(OnlyOfficeSceneConfigBuilder builder, OnlyOfficeSessionEntity session, OnlyOfficeConfigParam param, String tenantId) { if (!builder.matchesSession(session, param, tenantId)) { throw new DataException("OnlyOffice 会话业务上下文不匹配"); } } private OnlyOfficeSessionEntity updateExistingSession(OnlyOfficeSessionEntity session, OnlyOfficeConfigParam param, String documentType, boolean updateBusinessContext) { UpdateWrapper update = Wrappers.update() .eq("f_id", session.getId()) .set("file_name", param.getFileName()) .set("document_type", documentType); if (updateBusinessContext && !ObjectUtils.isEmpty(param.getBizModule())) { session.setBizModule(param.getBizModule()); update.set("biz_module", param.getBizModule()); } if (updateBusinessContext && !ObjectUtils.isEmpty(param.getBizDataId())) { session.setBizDataId(param.getBizDataId()); update.set("biz_data_id", param.getBizDataId()); } if (isQianpiye(param)) { // 兼容已有签批页会话:旧记录没有 biz_scene,也必须在本次打开后走另存为策略。 session.setBizScene(SCENE_DMS_QIANPIYE); update.set("biz_scene", SCENE_DMS_QIANPIYE); } session.setFileName(param.getFileName()); session.setDocumentType(documentType); sessionMapper.update(null, update); return session; } private OnlyOfficeSessionEntity loadOrCreateSession(OnlyOfficeConfigParam param, String documentType, String tenantId) { return loadOrCreateSession(param, documentType, tenantId, sceneConfigRegistry.resolve(param)); } /** * 决定最终模式。前端传的只是期望值,以下四种情况一律降级为只读: * 格式本身不可编辑、当前用户无编辑权、他人正持有编辑锁、DS 上一次保存失败 * (避免在错误状态上继续写)。 */ private String resolveMode(OnlyOfficeConfigParam param, String extension, OnlyOfficeSessionEntity session, UserInfo user) { OnlyOfficeSceneConfigBuilder builder = sceneConfigRegistry.resolve(param); OnlyOfficeSceneBuildPlan plan = builder.build(new OnlyOfficeSceneBuildContext( param, session, user, tenantIdOf(user))); return resolveMode(param, extension, session, user, plan); } private String resolveMode(OnlyOfficeConfigParam param, String extension, OnlyOfficeSessionEntity session, UserInfo user, OnlyOfficeSceneBuildPlan scenePlan) { if (isDms(param)) { return MODE_VIEW; } if (ObjectUtils.isEmpty(param.getBizScene()) && !MODE_QIANPIYE.equals(param.getMode())) { return MODE_VIEW; } if (MODE_VIEW.equals(param.getMode()) || "viewComments".equals(param.getMode())) { return MODE_VIEW; } if (StringUtils.hasText(param.getLocateControlTag())) { return MODE_VIEW; } // DOC 仅对有独立产物回写的上传编辑场景放行,其他场景保持原有格式策略。 boolean uploadDoc = "doc".equals(extension) && UploadEditSceneConfigBuilder.SCENE.equals(param.getBizScene()) && UploadEditSceneConfigBuilder.MODULE.equals(param.getBizModule()); if (!EDITABLE_EXTENSIONS.contains(extension) && !uploadDoc) { return MODE_VIEW; } if (!scenePlan.canEdit()) { return MODE_VIEW; } if (Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.SAVE_FAILED).equals(session.getSessionStatus())) { log.warn("文档 {} 上次保存失败,本次降级只读", session.getFileId()); return MODE_VIEW; } if (Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.PUBLISHING) .equals(session.getSessionStatus())) { log.info("文档 {} 正在发布,本次降级只读", session.getFileId()); return MODE_VIEW; } if (Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING) .equals(session.getSessionStatus()) || Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.RECORD_HISTORICAL) .equals(session.getSessionStatus())) { log.info("记录文档 {} 正在修订或已成为历史版本,本次降级只读", session.getFileId()); return MODE_VIEW; } boolean sameUserReopen = scenePlan.allowSameUserLockReopen() && Objects.equals(session.getLockUserId(), user.getUserId()); if (isLockActive(session) && !sameUserReopen) { log.info("文档 {} 正被 {} 编辑,{} 降级只读", session.getFileId(), session.getLockUserId(), user.getUserId()); return MODE_VIEW; } return MODE_EDIT; } private OnlyOfficeSessionEntity reloadFencedSession(OnlyOfficeConfigParam param, String tenantId, OnlyOfficeSceneConfigBuilder sceneBuilder) { OnlyOfficeSessionEntity session = sessionMapper.selectOne(Wrappers.lambdaQuery() .eq(OnlyOfficeSessionEntity::getTenantId, tenantId) .eq(OnlyOfficeSessionEntity::getFileId, param.getFileId()) .eq(OnlyOfficeSessionEntity::getFileType, param.getFileType()) .last("limit 1")); Integer status = session == null ? null : session.getSessionStatus(); if (session != null && sceneBuilder.acceptsFencedStatus(status) && sceneBuilder.matchesSession(session, param, tenantId)) { return session; } return null; } private boolean isTemplateEditable(String tenantId, String templateId, String fileId) { try { ActionResult result = templateEditabilityApi.editable(tenantId, templateId, fileId); return result != null && ActionResultCode.SUCCESS.getCode().equals(result.getCode()) && Boolean.TRUE.equals(result.getData()); } catch (RuntimeException e) { log.warn("ELN 模板可编辑性探针调用失败,template={} file={}", templateId, fileId); return false; } } private String tenantIdOf(UserInfo user) { return user == null || !StringUtils.hasText(user.getTenantId()) ? "0" : user.getTenantId(); } /** * 锁是否仍有效。当前不支持协同,即使持有者是同一用户,新标签页也只能只读, * 保证既有编辑标签的租约和 callback 关联不会被新 config 覆盖。 */ private boolean isLockActive(OnlyOfficeSessionEntity session) { String holder = session.getLockUserId(); if (ObjectUtils.isEmpty(holder)) { return false; } Date heartbeat = session.getLockHeartbeat(); if (heartbeat == null) { return false; } long timeoutMillis = properties.getLockTimeoutMinutes() * 60L * 1000L; return System.currentTimeMillis() - heartbeat.getTime() < timeoutMillis; } private int acquireLock(OnlyOfficeSessionEntity session, String userId) { if (ObjectUtils.isEmpty(session.getId()) || ObjectUtils.isEmpty(session.getDocKey()) || session.getVersionNo() == null) { throw new DataException("文档编辑锁获取失败,请重新打开"); } if (Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.SAVING) .equals(session.getSessionStatus())) { throw new DataException("文档编辑锁获取失败,请重新打开"); } if (Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.PUBLISHING) .equals(session.getSessionStatus())) { throw new DataException("文档编辑锁获取失败,请重新打开"); } if (Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING) .equals(session.getSessionStatus()) || Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.RECORD_HISTORICAL) .equals(session.getSessionStatus())) { throw new DataException("历史记录文档不可编辑"); } Integer currentLockVersion = session.getVersion(); if (Integer.valueOf(Integer.MAX_VALUE).equals(currentLockVersion)) { throw new DataException("文档编辑锁版本已耗尽,请重新创建会话"); } // f_version 在每次成功获取锁时递增,作为失败补偿的 fencing token。 int acquiredLockVersion = currentLockVersion == null ? 1 : currentLockVersion + 1; Date heartbeat = new Date(); Date expiredBefore = new Date(heartbeat.getTime() - properties.getLockTimeoutMinutes() * 60L * 1000L); OnlyOfficeSessionEntity lock = new OnlyOfficeSessionEntity(); String leaseDocKey = buildLeaseDocKey(session.getFileId(), session.getVersionNo(), acquiredLockVersion); lock.setLockUserId(userId); lock.setLockHeartbeat(heartbeat); lock.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); lock.setVersion(acquiredLockVersion); lock.setDocKey(leaseDocKey); UpdateWrapper acquire = Wrappers.update() .eq("f_id", session.getId()) .eq("doc_key", session.getDocKey()) .eq("version_no", session.getVersionNo()) .and(valid -> valid.isNull("f_delete_mark").or().eq("f_delete_mark", 0)) .and(valid -> valid.isNull("session_status") .or().eq("session_status", OnlyOfficeSessionEntity.SessionStatus.IDLE) .or().eq("session_status", OnlyOfficeSessionEntity.SessionStatus.EDITING)) .and(available -> available.isNull("lock_user_id") .or().eq("lock_user_id", "") .or().eq("lock_user_id", userId) .or().isNull("lock_heartbeat") .or().lt("lock_heartbeat", expiredBefore)); if (isQianpiyeSession(session)) { // 新取得的编辑锁意味着打开了新的签批页编辑会话;首次保存应再次另存为。 acquire.set("qianpiye_saved_file_json", null); } if (currentLockVersion == null) { acquire.isNull("f_version"); } else { acquire.eq("f_version", currentLockVersion); } int affected = sessionMapper.update(lock, acquire); if (affected != 1) { throw new LockCasConflictException(); } session.setLockUserId(userId); session.setLockHeartbeat(heartbeat); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); session.setVersion(acquiredLockVersion); session.setDocKey(leaseDocKey); if (isQianpiyeSession(session)) { session.setQianpiyeSavedFileJson(null); } return acquiredLockVersion; } // ── config 组装 ───────────────────────────────────────────────────────── private Map assembleConfig(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session, String documentType, String extension, String mode, UserInfo user, List resolvedPlugins) { OnlyOfficeSceneConfigBuilder builder = sceneConfigRegistry.resolve(param); OnlyOfficeSceneBuildPlan plan = builder.build(new OnlyOfficeSceneBuildContext( param, session, user, tenantIdOf(user))); return assembleConfig(param, session, documentType, extension, mode, user, resolvedPlugins, plan, false); } private Map assembleConfig(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session, String documentType, String extension, String mode, UserInfo user, List resolvedPlugins, OnlyOfficeSceneBuildPlan scenePlan, boolean dmsPrint) { boolean writableSession = MODE_EDIT.equals(mode); List activePlugins = pluginsForEditorMode( param, mode, resolvedPlugins, scenePlan); boolean reviewOnly = writableSession && ("review".equals(param.getMode()) || scenePlan.reviewOnly()); boolean commentPreview = !writableSession && "viewComments".equals(param.getMode()); boolean hideComments = !reviewOnly && !commentPreview && (!writableSession || scenePlan.hideCommentsWhenWritable()); boolean qianpiyeMode = isQianpiye(param); // 保留既有场景的导出兼容行为;上传编辑使用已验证的原生保存路径。 boolean allowDownload = properties.isAllowDownload(); boolean allowPrint = properties.isAllowPrint(); if (scenePlan.uploadFileAllowDownload() != null) { allowDownload = allowDownload && scenePlan.uploadFileAllowDownload(); } else if (writableSession && !allowDownload && !allowPrint) { allowDownload = true; } Map permissions = isDms(param) ? OnlyOfficeUiConfig.permissions(false, true, false, dmsPrint) : qianpiyeMode ? OnlyOfficeUiConfig.qianpiyePermissions(writableSession, hideComments) : OnlyOfficeUiConfig.permissions(writableSession, hideComments, allowDownload, allowPrint); if (reviewOnly) OnlyOfficeUiConfig.applyReviewPermissions(permissions); if (commentPreview) OnlyOfficeUiConfig.applyCommentPreviewPermissions(permissions); Map document = new LinkedHashMap<>(); document.put("fileType", extension); document.put("key", documentKey(param, session)); document.put("title", param.getFileName()); document.put("url", scenePlan.sourceFileId() == null ? buildFileFetchUrl(param, user) : properties.getFileFetchBaseUrl() + "/api/file/Image/annex/" + URLEncodeUtil.encode(scenePlan.sourceFileId(), GlobalConst.DEFAULT_CHARSET) + "?s=" + user.getSecurityKey() + "&t=t"); document.put("permissions", permissions); Map owner = new LinkedHashMap<>(); owner.put("id", user.getUserId()); owner.put("name", user.getUserName()); Map customization = isDms(param) ? OnlyOfficeUiConfig.previewCustomization() : qianpiyeMode ? OnlyOfficeUiConfig.qianpiyeCustomization(writableSession, hideComments) : OnlyOfficeUiConfig.customization(writableSession, !activePlugins.isEmpty(), hideComments); if (reviewOnly || commentPreview) OnlyOfficeUiConfig.showReviewComments(customization, reviewOnly); // 协同不在本期范围,但 DS 没有关闭协同的开关(coEditing 只有 fast/strict)。 // strict + change:false 让行为至少可预期:改动要保存后才互相可见,且禁掉界面切换入口 // (用户在界面改过模式会写进 localStorage 并覆盖此处传值)。真正的隔离靠编辑锁。 Map coEditing = new LinkedHashMap<>(); coEditing.put("mode", "strict"); coEditing.put("change", false); Map editorConfig = new LinkedHashMap<>(); if (!commentPreview) 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 (!activePlugins.isEmpty()) { Map plugins = new LinkedHashMap<>(); List pluginsData = new ArrayList<>(); List autostart = new ArrayList<>(); Map options = new LinkedHashMap<>(); for (OnlyOfficePluginResolver.ResolvedPlugin resolvedPlugin : activePlugins) { pluginsData.add(resolvedPlugin.getManifestUrl()); if (resolvedPlugin.isAutostart()) { autostart.add(resolvedPlugin.getGuid()); } boolean uploadQianpiyePlugin = scenePlan.uploadQianpiyeAnnotatorEnabled() && SCENE_DMS_QIANPIYE.equals(resolvedPlugin.getBizScene()); String pluginDocumentId = uploadQianpiyePlugin ? scenePlan.sourceFileId() : param.getFileId(); Map context = resolvedPlugin.runtimeContext(pluginDocumentId, uploadQianpiyePlugin ? null : param.getBizDataId()); scenePlan.customizePluginContext(resolvedPlugin.getPluginCode(), resolvedPlugin.getGuid(), context, writableSession); if (SCENE_DMS_QIANPIYE.equals(resolvedPlugin.getBizScene())) { log.info("签批页插件上下文已签发:fileId={} fileType={} mode={} writable={} fields={} hasTicket={}", param.getFileId(), param.getFileType(), mode, writableSession, context.keySet(), context.containsKey("ticket")); } options.put(resolvedPlugin.getGuid(), context); } plugins.put("pluginsData", pluginsData); plugins.put("options", options); if (!autostart.isEmpty()) { plugins.put("autostart", autostart); } List disabledPluginGuids = activePlugins.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); if (qianpiyeMode) { Map qianpiye = new LinkedHashMap<>(); qianpiye.put("enabled", true); qianpiye.put("mode", MODE_QIANPIYE); config.put(SCENE_DMS_QIANPIYE, qianpiye); } // token 的 payload 就是上面这份 config 本身;DS 收到后用同一密钥验签, // 防止浏览器篡改 permissions / callbackUrl。必须最后放,不能把自己算进去。 config.put("token", JWTUtil.createToken(config, requireJwtSecret().getBytes(StandardCharsets.UTF_8))); return config; } private List pluginsForEditorMode( OnlyOfficeConfigParam param, String mode, List resolvedPlugins, OnlyOfficeSceneBuildPlan scenePlan) { if (!MODE_VIEW.equals(mode)) { return resolvedPlugins; } List activePlugins = new ArrayList<>(); for (OnlyOfficePluginResolver.ResolvedPlugin plugin : resolvedPlugins) { if (scenePlan.keepPluginInReadOnly(plugin.getPluginCode(), StringUtils.hasText(param.getLocateControlTag()))) { activePlugins.add(plugin); } } return activePlugins; } /** * 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) { if (isDms(param)) { return properties.getFileFetchBaseUrl() + "/api/file/Dms/OnlyOffice/" + param.getFileId() + "?ticket=" + URLEncodeUtil.encode(param.getDmsAccessTicket(), GlobalConst.DEFAULT_CHARSET); } return properties.getFileFetchBaseUrl() + "/api/file/Image/" + param.getFileType() + "/" + param.getFileId() + "?s=" + user.getSecurityKey() + "&t=t"; } private boolean isDms(OnlyOfficeConfigParam param) { return "dms".equalsIgnoreCase(param.getFileType()); } private boolean isQianpiye(OnlyOfficeConfigParam param) { return MODE_QIANPIYE.equals(param.getMode()) || SCENE_DMS_QIANPIYE.equals(param.getBizScene()); } // ── 工具 ──────────────────────────────────────────────────────────────── /** * 取签名密钥,顺带挡住「占位符没被解析」这种静默失败。 * *

配置写的是 {@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 buildLeaseDocKey(String fileId, int versionNo, int lockVersion) { return DigestUtil.md5Hex(fileId + "_" + versionNo + "_lease_" + lockVersion); } private String documentKey(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session) { if (isDms(param)) { return DigestUtil.md5Hex(param.getFileId() + "_dms_" + param.getDmsAccessTicket()); } return session.getDocKey(); } 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(); } }