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();
}
}