package jnpf.bizcommon.onlyoffice.service.impl; import cn.hutool.core.util.IdUtil; import cn.hutool.crypto.digest.DigestUtil; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeProperties; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeSessionEntity; import jnpf.bizcommon.onlyoffice.mapper.OnlyOfficeSessionMapper; import jnpf.exception.DataException; import lombok.RequiredArgsConstructor; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.Date; import java.util.UUID; /** 在 OnlyOffice 会话状态机内为 ELN 记录修订提供互斥 fence。 */ @Service @RequiredArgsConstructor public class OnlyOfficeRecordRevisionFenceService { static final String ELN_RECORD_BIZ_MODULE = "eln.record"; private static final String FREEZE_TOKEN_PREFIX = "freeze:"; private static final String CANCELLED_TOKEN_PREFIX = "cancelled:"; private final OnlyOfficeSessionMapper sessionMapper; private final OnlyOfficeFreezeCancellationStore cancellationStore; private final OnlyOfficeProperties properties; public String acquire(String tenantId, String recordId, String fileId, String fileType) { return acquireWithToken(tenantId, recordId, fileId, fileType, UUID.randomUUID().toString(), false); } public String acquireForOperation(String tenantId, String recordId, String fileId, String fileType, String operationCode) { requireIdentity(tenantId, recordId, fileId, fileType); if (!StringUtils.hasText(operationCode)) throw new DataException("记录冻结操作码不能为空"); String token = FREEZE_TOKEN_PREFIX + operationCode.trim(); rejectIfCancelled(tenantId, recordId, fileId, fileType, token); return acquireWithToken(tenantId, recordId, fileId, fileType, token, true); } private String acquireWithToken(String tenantId, String recordId, String fileId, String fileType, String token, boolean idempotent) { requireIdentity(tenantId, recordId, fileId, fileType); OnlyOfficeSessionEntity existing = find(tenantId, fileId, fileType); if (idempotent && isCancelled(existing, recordId, token)) throw cancelled(); if (idempotent && ownsFence(existing, recordId, token)) return token; if (existing != null) { if (idempotent) rejectIfCancelled(tenantId, recordId, fileId, fileType, token); acquireExisting(existing, tenantId, recordId, fileId, fileType, token); if (idempotent) rejectIfCancelled(tenantId, recordId, fileId, fileType, token); return token; } if (idempotent) rejectIfCancelled(tenantId, recordId, fileId, fileType, token); OnlyOfficeSessionEntity placeholder = placeholder(tenantId, recordId, fileId, fileType, OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING, token); try { if (sessionMapper.insert(placeholder) == 1) { if (idempotent) rejectIfCancelled(tenantId, recordId, fileId, fileType, token); return token; } } catch (DuplicateKeyException ignored) { // 编辑器首次打开或同操作并发插入时,由文件唯一键裁决后重读。 } existing = find(tenantId, fileId, fileType); if (idempotent && isCancelled(existing, recordId, token)) throw cancelled(); if (idempotent && ownsFence(existing, recordId, token)) return token; if (existing == null) throw busy(); if (idempotent) rejectIfCancelled(tenantId, recordId, fileId, fileType, token); acquireExisting(existing, tenantId, recordId, fileId, fileType, token); if (idempotent) rejectIfCancelled(tenantId, recordId, fileId, fileType, token); return token; } private void rejectIfCancelled(String tenantId, String recordId, String fileId, String fileType, String token) { boolean cancelled; try { cancelled = cancellationStore.isCancelled(tenantId, recordId, fileId, fileType, token); } catch (RuntimeException e) { cancelOrReleaseOperation(tenantId, recordId, fileId, fileType, token); throw e; } if (!cancelled) return; cancelOrReleaseOperation(tenantId, recordId, fileId, fileType, token); throw cancelled(); } private static OnlyOfficeSessionEntity placeholder(String tenantId, String recordId, String fileId, String fileType, int status, String token) { OnlyOfficeSessionEntity placeholder = new OnlyOfficeSessionEntity(); placeholder.setId(IdUtil.getSnowflakeNextIdStr()); placeholder.setTenantId(tenantId); placeholder.setFileId(fileId); placeholder.setFileType(fileType); placeholder.setFileName(fileId); placeholder.setDocumentType("word"); placeholder.setBizModule(ELN_RECORD_BIZ_MODULE); placeholder.setBizDataId(recordId); placeholder.setVersionNo(1); placeholder.setVersion(1); placeholder.setSessionStatus(status); placeholder.setPublishToken(token); placeholder.setDocKey(DigestUtil.md5Hex(fileId + "_1")); return placeholder; } private static boolean ownsFence(OnlyOfficeSessionEntity existing, String recordId, String token) { return existing != null && ELN_RECORD_BIZ_MODULE.equals(existing.getBizModule()) && recordId.equals(existing.getBizDataId()) && Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING) .equals(existing.getSessionStatus()) && token.equals(existing.getPublishToken()); } private static boolean isCancelled(OnlyOfficeSessionEntity existing, String recordId, String token) { return existing != null && ELN_RECORD_BIZ_MODULE.equals(existing.getBizModule()) && recordId.equals(existing.getBizDataId()) && Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.IDLE) .equals(existing.getSessionStatus()) && (CANCELLED_TOKEN_PREFIX + token).equals(existing.getPublishToken()); } public boolean complete(String tenantId, String recordId, String fileId, String fileType, String token) { return finish(tenantId, recordId, fileId, fileType, token, OnlyOfficeSessionEntity.SessionStatus.RECORD_HISTORICAL); } public boolean release(String tenantId, String recordId, String fileId, String fileType, String token) { if (StringUtils.hasText(token) && token.startsWith(FREEZE_TOKEN_PREFIX)) { cancellationStore.cancel(tenantId, recordId, fileId, fileType, token); return cancelOrReleaseOperation(tenantId, recordId, fileId, fileType, token); } return finish(tenantId, recordId, fileId, fileType, token, OnlyOfficeSessionEntity.SessionStatus.IDLE); } public boolean releaseAfterDocumentUpdate( String tenantId, String recordId, String fileId, String fileType, String token) { requireIdentity(tenantId, recordId, fileId, fileType); if (!StringUtils.hasText(token)) { throw new DataException("记录修订 fence token 不能为空"); } OnlyOfficeSessionEntity existing = find(tenantId, fileId, fileType); if (!ownsFence(existing, recordId, token)) return false; int nextVersionNo = existing.getVersionNo() == null ? 1 : existing.getVersionNo() + 1; int nextVersion = existing.getVersion() == null ? 1 : existing.getVersion() + 1; UpdateWrapper update = identityUpdate( tenantId, recordId, fileId, fileType) .eq("f_id", existing.getId()) .eq("session_status", OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING) .eq("publish_token", token) .set("session_status", OnlyOfficeSessionEntity.SessionStatus.IDLE) .set("publish_token", null) .set("lock_user_id", null) .set("lock_heartbeat", null) .set("last_save_time", new Date()) .set("version_no", nextVersionNo) .set("doc_key", DigestUtil.md5Hex(fileId + "_" + nextVersionNo)) .set("f_version", nextVersion); if (existing.getVersionNo() == null) { update.isNull("version_no"); } else { update.eq("version_no", existing.getVersionNo()); } if (existing.getVersion() == null) { update.isNull("f_version"); } else { update.eq("f_version", existing.getVersion()); } return sessionMapper.update(null, update) == 1; } private boolean cancelOrReleaseOperation(String tenantId, String recordId, String fileId, String fileType, String token) { if (finish(tenantId, recordId, fileId, fileType, token, OnlyOfficeSessionEntity.SessionStatus.IDLE)) return true; String cancelledToken = CANCELLED_TOKEN_PREFIX + token; for (int attempt = 0; attempt < 3; attempt++) { OnlyOfficeSessionEntity existing = find(tenantId, fileId, fileType); if (isCancelled(existing, recordId, token)) return true; if (ownsFence(existing, recordId, token)) { if (finish(tenantId, recordId, fileId, fileType, token, OnlyOfficeSessionEntity.SessionStatus.IDLE)) return true; continue; } if (existing == null) { try { if (sessionMapper.insert(placeholder(tenantId, recordId, fileId, fileType, OnlyOfficeSessionEntity.SessionStatus.IDLE, cancelledToken)) == 1) return true; } catch (DuplicateKeyException ignored) { // 与 acquireForOperation 并发插入时重读,由文件唯一键裁决。 } continue; } if (!ELN_RECORD_BIZ_MODULE.equals(existing.getBizModule()) || !recordId.equals(existing.getBizDataId()) || !Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.IDLE) .equals(existing.getSessionStatus())) { // 当前非空闲状态会使同操作的 acquire CAS 失败,不会遗留本操作 fence。 return true; } if (transitionIdle(existing, tenantId, recordId, fileId, fileType, OnlyOfficeSessionEntity.SessionStatus.IDLE, cancelledToken)) return true; } return false; } private boolean finish(String tenantId, String recordId, String fileId, String fileType, String token, int targetStatus) { requireIdentity(tenantId, recordId, fileId, fileType); if (!StringUtils.hasText(token)) { throw new DataException("记录修订 fence token 不能为空"); } return sessionMapper.update(null, identityUpdate( tenantId, recordId, fileId, fileType) .eq("session_status", OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING) .eq("publish_token", token) .set("session_status", targetStatus) .set("publish_token", null) .set("lock_user_id", null) .set("lock_heartbeat", null)) == 1; } private void acquireExisting(OnlyOfficeSessionEntity existing, String tenantId, String recordId, String fileId, String fileType, String token) { if (!ELN_RECORD_BIZ_MODULE.equals(existing.getBizModule()) || !recordId.equals(existing.getBizDataId()) || !isAvailable(existing)) { throw busy(); } if (!transitionAvailable(existing, tenantId, recordId, fileId, fileType, OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING, token)) throw busy(); } private boolean isAvailable(OnlyOfficeSessionEntity existing) { if (Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.IDLE) .equals(existing.getSessionStatus())) return true; if (!Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.EDITING) .equals(existing.getSessionStatus())) return false; if (!StringUtils.hasText(existing.getLockUserId()) || existing.getLockHeartbeat() == null) return true; long timeoutMillis = properties.getLockTimeoutMinutes() * 60L * 1000L; return System.currentTimeMillis() - existing.getLockHeartbeat().getTime() >= timeoutMillis; } private boolean transitionAvailable(OnlyOfficeSessionEntity existing, String tenantId, String recordId, String fileId, String fileType, int targetStatus, String token) { int nextVersion = existing.getVersion() == null ? 1 : existing.getVersion() + 1; java.util.Date expiredBefore = new java.util.Date(System.currentTimeMillis() - properties.getLockTimeoutMinutes() * 60L * 1000L); UpdateWrapper update = identityUpdate(tenantId, recordId, fileId, fileType) .eq("f_id", existing.getId()).eq("doc_key", existing.getDocKey()) .eq("version_no", existing.getVersionNo()) .and(available -> available.eq("session_status", OnlyOfficeSessionEntity.SessionStatus.IDLE) .or(editing -> editing .eq("session_status", OnlyOfficeSessionEntity.SessionStatus.EDITING) .and(expired -> expired.isNull("lock_user_id").or().eq("lock_user_id", "") .or().isNull("lock_heartbeat").or().lt("lock_heartbeat", expiredBefore)))) .set("session_status", targetStatus).set("publish_token", token) .set("lock_user_id", null).set("lock_heartbeat", null) .set("f_version", nextVersion); if (existing.getVersion() == null) { update.isNull("f_version"); } else { update.eq("f_version", existing.getVersion()); } return sessionMapper.update(null, update) == 1; } private boolean transitionIdle(OnlyOfficeSessionEntity existing, String tenantId, String recordId, String fileId, String fileType, int targetStatus, String token) { int nextVersion = existing.getVersion() == null ? 1 : existing.getVersion() + 1; UpdateWrapper update = identityUpdate(tenantId, recordId, fileId, fileType) .eq("f_id", existing.getId()).eq("doc_key", existing.getDocKey()) .eq("version_no", existing.getVersionNo()) .eq("session_status", OnlyOfficeSessionEntity.SessionStatus.IDLE) .set("session_status", targetStatus).set("publish_token", token) .set("lock_user_id", null).set("lock_heartbeat", null) .set("f_version", nextVersion); if (existing.getVersion() == null) { update.isNull("f_version"); } else { update.eq("f_version", existing.getVersion()); } return sessionMapper.update(null, update) == 1; } private OnlyOfficeSessionEntity find(String tenantId, String fileId, String fileType) { return sessionMapper.selectOne(Wrappers.lambdaQuery() .eq(OnlyOfficeSessionEntity::getTenantId, tenantId) .eq(OnlyOfficeSessionEntity::getFileId, fileId) .eq(OnlyOfficeSessionEntity::getFileType, fileType) .last("limit 1")); } private UpdateWrapper identityUpdate( String tenantId, String recordId, String fileId, String fileType) { return Wrappers.update() .eq("f_tenant_id", tenantId) .eq("file_id", fileId) .eq("file_type", fileType) .eq("biz_module", ELN_RECORD_BIZ_MODULE) .eq("biz_data_id", recordId) .and(active -> active.isNull("f_delete_mark").or().eq("f_delete_mark", 0)); } private static void requireIdentity( String tenantId, String recordId, String fileId, String fileType) { if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(recordId) || !StringUtils.hasText(fileId) || !StringUtils.hasText(fileType)) { throw new DataException("记录修订 fence 身份参数不完整"); } } private static DataException busy() { return new DataException("记录文档正在编辑或保存,请关闭编辑器后重试"); } private static DataException cancelled() { return new DataException("记录冻结操作已取消,请重新提交"); } }