刘光辉
8 小时以前 06f314545ac3514d7ce28939920866ae8188d31c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
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("记录冻结操作已取消,请重新提交");
    }
}