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<OnlyOfficeSessionEntity> 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<OnlyOfficeSessionEntity> 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<OnlyOfficeSessionEntity> 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.<OnlyOfficeSessionEntity>lambdaQuery()
|
.eq(OnlyOfficeSessionEntity::getTenantId, tenantId)
|
.eq(OnlyOfficeSessionEntity::getFileId, fileId)
|
.eq(OnlyOfficeSessionEntity::getFileType, fileType)
|
.last("limit 1"));
|
}
|
|
private UpdateWrapper<OnlyOfficeSessionEntity> identityUpdate(
|
String tenantId, String recordId, String fileId, String fileType) {
|
return Wrappers.<OnlyOfficeSessionEntity>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("记录冻结操作已取消,请重新提交");
|
}
|
}
|