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 的签发。
|
*
|
* <p>本类只负责「打开文档」这一段。三段链路的凭据各不相同:打开走用户登录 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<String, String> DOCUMENT_TYPES = new HashMap<>();
|
|
static {
|
putAll("word", "doc", "docx", "docm", "dot", "dotx", "odt", "ott", "rtf", "txt", "html", "htm");
|
putAll("cell", "xls", "xlsx", "xlsm", "xlt", "xltx", "ods", "ots", "csv");
|
putAll("slide", "ppt", "pptx", "pptm", "pot", "potx", "pps", "ppsx", "odp", "otp");
|
putAll("pdf", "pdf");
|
}
|
|
private static void putAll(String documentType, String... extensions) {
|
for (String extension : extensions) {
|
DOCUMENT_TYPES.put(extension, documentType);
|
}
|
}
|
|
/** 可编辑的格式;其余(如 pdf、txt 之外的只读格式)一律降级为只读 */
|
private static final Set<String> EDITABLE_EXTENSIONS = new HashSet<>(Arrays.asList(
|
"docx", "xlsx", "pptx", "docm", "xlsm", "pptm", "odt", "ods", "odp", "rtf", "txt", "csv"));
|
|
private static final String MODE_VIEW = "view";
|
private static final String MODE_EDIT = "edit";
|
private static final String 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<String, Object> 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<OnlyOfficePluginResolver.ResolvedPlugin> 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<String, Object> 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.<OnlyOfficeSessionEntity>lambdaQuery()
|
.eq(OnlyOfficeSessionEntity::getTenantId, tenantId)
|
.eq(OnlyOfficeSessionEntity::getFileId, fileId)
|
.eq(OnlyOfficeSessionEntity::getFileType, fileType)
|
.last("limit 1"));
|
Map<String, Object> status = new LinkedHashMap<>();
|
status.put("version", session == null || session.getVersionNo() == null ? 0 : session.getVersionNo());
|
status.put("lastSaveTime", session == null || session.getLastSaveTime() == null
|
? null : session.getLastSaveTime().getTime());
|
status.put("sessionStatus", session == null
|
? "IDLE" : sessionStatusName(session.getSessionStatus()));
|
if (isQianpiyeSession(session)) {
|
Map<String, Object> 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.<OnlyOfficeSessionEntity>lambdaQuery()
|
.eq(OnlyOfficeSessionEntity::getDocKey, param.getKey())
|
.last("limit 1"));
|
if (session == null) {
|
// 会话被清理或 key 对不上。抛错让 Controller 记日志,但仍返回 error:0——
|
// 返回非 0 会让 DS 无限重试一个永远不会成功的回调。
|
throw new DataException("找不到 doc_key 对应的编辑会话:" + param.getKey());
|
}
|
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 产出的新版文档写回文件存储。
|
*
|
* <p>普通场景原地覆盖同一个 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<String, Object> 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<OnlyOfficeSessionEntity> 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<String, Object> saveQianpiyeCopy(OnlyOfficeSessionEntity session,
|
byte[] candidate, String folderPath) {
|
Map<String, Object> 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<String, Object> 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<String, Object> qianpiyeSavedFile(OnlyOfficeSessionEntity session) {
|
if (session == null || !StringUtils.hasText(session.getQianpiyeSavedFileJson())) {
|
return null;
|
}
|
try {
|
Map<String, Object> 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<RecordFillSaveOperation> 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<OnlyOfficeSessionEntity> leaseUpdate(OnlyOfficeSessionEntity session, int status) {
|
UpdateWrapper<OnlyOfficeSessionEntity> update = Wrappers.<OnlyOfficeSessionEntity>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.<OnlyOfficeSessionEntity>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.<OnlyOfficeSessionEntity>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.<OnlyOfficeSessionEntity>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.<OnlyOfficeSessionEntity>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<OnlyOfficeSessionEntity> update = Wrappers.<OnlyOfficeSessionEntity>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.<OnlyOfficeSessionEntity>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<Boolean> 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<OnlyOfficeSessionEntity> acquire = Wrappers.<OnlyOfficeSessionEntity>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<String, Object> assembleConfig(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session,
|
String documentType, String extension, String mode, UserInfo user,
|
List<OnlyOfficePluginResolver.ResolvedPlugin> 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<String, Object> assembleConfig(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session,
|
String documentType, String extension, String mode, UserInfo user,
|
List<OnlyOfficePluginResolver.ResolvedPlugin> resolvedPlugins,
|
OnlyOfficeSceneBuildPlan scenePlan, boolean dmsPrint) {
|
boolean writableSession = MODE_EDIT.equals(mode);
|
List<OnlyOfficePluginResolver.ResolvedPlugin> 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<String, Object> 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<String, Object> 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<String, Object> owner = new LinkedHashMap<>();
|
owner.put("id", user.getUserId());
|
owner.put("name", user.getUserName());
|
|
Map<String, Object> 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<String, Object> coEditing = new LinkedHashMap<>();
|
coEditing.put("mode", "strict");
|
coEditing.put("change", false);
|
|
Map<String, Object> 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<String, Object> plugins = new LinkedHashMap<>();
|
List<String> pluginsData = new ArrayList<>();
|
List<String> autostart = new ArrayList<>();
|
Map<String, Object> options = new LinkedHashMap<>();
|
for (OnlyOfficePluginResolver.ResolvedPlugin resolvedPlugin : 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<String, Object> 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<String> disabledPluginGuids = activePlugins.get(0).getDisabledPluginGuids();
|
if (!disabledPluginGuids.isEmpty()) {
|
plugins.put("disable", disabledPluginGuids);
|
}
|
editorConfig.put("plugins", plugins);
|
}
|
|
Map<String, Object> config = new LinkedHashMap<>();
|
config.put("document", document);
|
config.put("documentType", documentType);
|
config.put("editorConfig", editorConfig);
|
if (qianpiyeMode) {
|
Map<String, Object> 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<OnlyOfficePluginResolver.ResolvedPlugin> pluginsForEditorMode(
|
OnlyOfficeConfigParam param, String mode,
|
List<OnlyOfficePluginResolver.ResolvedPlugin> resolvedPlugins,
|
OnlyOfficeSceneBuildPlan scenePlan) {
|
if (!MODE_VIEW.equals(mode)) {
|
return resolvedPlugins;
|
}
|
List<OnlyOfficePluginResolver.ResolvedPlugin> 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 票据。
|
*
|
* <p>🔴 {@code &t=t} 不能省,且它与"时间戳"无关。{@code FileServiceImpl#flushFile} 里
|
* {@code redirect = StringUtil.isEmpty(t)}:**带 securityKey 但不带 t 时,该端点不吐文件流,
|
* 而是 302 跳到 ApiDomain 上的预览地址**。DS 会跟着跳,最终拿到
|
* {@code {"code":400,"msg":"System abnormality"}} 并把这段 JSON 存成 origin.docx,
|
* 表现为编辑器一直转圈(HTTP 200 让 DS 以为下载成功了)。前端的
|
* {@code getAuthMediaUrl(url, isRedirect=false)} 也是靠追加 {@code &t=t} 拿原始流的,此处同源。
|
* 2026-08-08 实踩。
|
*/
|
private String buildFileFetchUrl(OnlyOfficeConfigParam param, UserInfo user) {
|
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());
|
}
|
|
// ── 工具 ────────────────────────────────────────────────────────────────
|
|
/**
|
* 取签名密钥,顺带挡住「占位符没被解析」这种静默失败。
|
*
|
* <p>配置写的是 {@code ${ONLYOFFICE_JWT_SECRET}},而 **Spring Boot 的 Binder 对解析不了的
|
* 占位符是原样放行**(不抛异常)。所以变量没透传进容器时,这里拿到的是字面量
|
* {@code "${ONLYOFFICE_JWT_SECRET}"}——非空、能过空值校验、能签出 token,但 DS 验签必然
|
* 失败,表现为编辑器里「文档安全令牌的格式不正确」,且后端毫无异常可查。
|
* 2026-08-08 实踩过一次(compose 只把该变量喂给了 cx-onlyoffice,漏了 cx-biz-common)。
|
*/
|
private String requireJwtSecret() {
|
String secret = properties.getJwtSecret();
|
if (ObjectUtils.isEmpty(secret)) {
|
throw new DataException("OnlyOffice 未配置 jwt-secret,无法签发/校验令牌");
|
}
|
if (secret.startsWith("${")) {
|
throw new DataException("OnlyOffice 的 jwt-secret 仍是未解析的占位符(" + secret
|
+ "),说明运行环境没有注入 ONLYOFFICE_JWT_SECRET——容器栈检查 docker-compose.yml"
|
+ " 的 cx-biz-common environment,宿主栈检查 .env 是否被 start-all.sh 加载");
|
}
|
return secret;
|
}
|
|
/**
|
* doc_key:DS 用它判断「是不是同一份文档的同一个版本」。
|
* md5 取 32 位十六进制,天然满足 DS 对字符集 [0-9a-zA-Z_-] 与长度 ≤128 的要求。
|
*/
|
private String buildDocKey(String fileId, int versionNo) {
|
return DigestUtil.md5Hex(fileId + "_" + versionNo);
|
}
|
|
private String 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();
|
}
|
}
|