package jnpf.base.util; import cn.hutool.core.util.IdUtil; import jnpf.base.model.uploadedit.UploadEditDraft; import jnpf.exception.DataException; import jnpf.util.JsonUtil; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** 状态转换只改变附件引用,从不覆盖/删除物理内容。调用者必须以 revision 做数据库 CAS。 */ public final class UploadEditStateMachine { /** Content identity is independent of lease/CAS revisions. Server files are immutable. */ public static String contentToken(Object files) { return cn.hutool.crypto.digest.DigestUtil.sha256Hex(jnpf.util.JsonUtil.getObjectToString( files(files).stream().map(file -> file.get("fileId")).toList())); } /** Business metadata only: timestamps and lease revisions do not mean content changed. */ public static String metadataToken(Object value) { return cn.hutool.crypto.digest.DigestUtil.sha256Hex(JsonUtil.getObjectToString(files(value).stream() .map(f -> Arrays.asList(f.get("fileId"), f.get(UploadTemplateSource.FIELD), f.get("contentSavedAt"))).toList())); } private UploadEditStateMachine() { } @SuppressWarnings("unchecked") public static List> files(Object value) { if (value == null || "".equals(value)) return new ArrayList<>(); Object parsed = value instanceof String ? JsonUtil.getJsonToBean((String) value, List.class) : value; if (!(parsed instanceof List)) throw new DataException("附件值不是有效列表"); List> result = new ArrayList<>(); for (Object item : (List) parsed) { if (!(item instanceof Map) || !(((Map) item).get("fileId") instanceof String) || ((String) ((Map) item).get("fileId")).trim().isEmpty()) { throw new DataException("附件缺少文件标识"); } Map map = (Map) item; String id = (String) map.get("fileId"); if (result.stream().anyMatch(file -> id.equals(file.get("fileId")))) throw new DataException("附件标识重复"); result.add(new LinkedHashMap<>((Map) map)); } return result; } public static boolean sameFiles(Object left, Object right) { return files(left).equals(files(right)); } public static void checkRevision(UploadEditDraft draft, Long revision) { if (revision == null || !Objects.equals(draft.getRevision(), revision)) { throw new DataException("附件已在其他页面更新,请重新打开表单"); } } public static void replace(UploadEditDraft draft, Long revision, List> next) { checkRevision(draft, revision); draft.setCurrentJson(JsonUtil.getObjectToString(files(next))); Map origins = editOrigins(draft); origins.keySet().removeIf(id -> next.stream().noneMatch(file -> id.equals(file.get("fileId")))); draft.setEditOriginsJson(JsonUtil.getObjectToString(origins)); invalidate(draft); advance(draft); } public static void discard(UploadEditDraft draft, Long revision) { checkRevision(draft, revision); draft.setCurrentJson(draft.getBaselineJson()); draft.setEditOriginsJson(null); invalidate(draft); advance(draft); } public static boolean supportsEditing(Object fileName) { String name = String.valueOf(fileName).toLowerCase(java.util.Locale.ROOT); return name.endsWith(".doc") || name.endsWith(".docx"); } public static Map begin(UploadEditDraft draft, Long revision, String fileId) { checkRevision(draft, revision); Map file = files(draft.getCurrentJson()).stream() .filter(item -> Objects.equals(fileId, item.get("fileId"))).findFirst() .orElseThrow(() -> new DataException("当前附件已被替换,请重新打开")); if (!supportsEditing(file.get("name"))) { throw new DataException("在线编辑目前仅支持 DOC、DOCX"); } invalidate(draft); draft.setLeaseId(IdUtil.simpleUUID()); draft.setLeaseFileId(fileId); advance(draft); return file; } /** 同一回调幂等;替换、放弃或表单采用后的租约不能再次生效。 */ public static boolean save(UploadEditDraft draft, String leaseId, String operationId, Map saved) { if (leaseId == null || !leaseId.equals(draft.getLeaseId())) return false; if (operationId == null || operationId.trim().isEmpty()) throw new DataException("保存操作标识不能为空"); if (operationId.equals(draft.getLastOperationId())) return true; List> current = files(draft.getCurrentJson()); Map replacement = files(java.util.Collections.singletonList(saved)).get(0); String sourceId = draft.getLeaseResultFileId() == null ? draft.getLeaseFileId() : draft.getLeaseResultFileId(); for (int i = 0; i < current.size(); i++) { if (!Objects.equals(sourceId, current.get(i).get("fileId"))) continue; Map origins = editOrigins(draft); String origin = origins.containsKey(sourceId) ? origins.remove(sourceId) : sourceId; origins.put(String.valueOf(saved.get("fileId")), origin); draft.setEditOriginsJson(JsonUtil.getObjectToString(origins)); Object savedAt = replacement.get("contentSavedAt"); UploadTemplateSource.inherit(replacement, current.get(i)); if (savedAt instanceof Number) replacement.put("contentSavedAt", savedAt); current.set(i, replacement); draft.setCurrentJson(JsonUtil.getObjectToString(current)); draft.setLeaseResultFileId(String.valueOf(saved.get("fileId"))); draft.setLastOperationId(operationId); advance(draft); return true; } return false; } public static void adopt(UploadEditDraft draft, Long revision, Object submitted) { checkRevision(draft, revision); if (!sameFiles(draft.getCurrentJson(), submitted)) { throw new DataException("提交附件不是当前工作文件,请刷新后重试"); } draft.setBaselineJson(draft.getCurrentJson()); draft.setEditOriginsJson(null); invalidate(draft); advance(draft); } /** 仅读取受 CAS 保护的服务器工作稿;客户端附件上的同名属性没有授权含义。 */ public static Map editOrigins(UploadEditDraft draft) { Map result = new LinkedHashMap<>(); if (draft.getEditOriginsJson() == null) return result; Map stored = JsonUtil.getJsonToBean(draft.getEditOriginsJson(), Map.class); if (stored == null) throw new DataException("附件编辑来源无效"); for (Map.Entry entry : stored.entrySet()) { if (!(entry.getKey() instanceof String) || !(entry.getValue() instanceof String) || ((String) entry.getKey()).trim().isEmpty() || ((String) entry.getValue()).trim().isEmpty()) { throw new DataException("附件编辑来源无效"); } result.put((String) entry.getKey(), (String) entry.getValue()); } return result; } public static void invalidate(UploadEditDraft draft) { draft.setLeaseId(null); draft.setLeaseFileId(null); draft.setLeaseResultFileId(null); draft.setLastOperationId(null); } private static void advance(UploadEditDraft draft) { draft.setRevision(Math.addExact(draft.getRevision(), 1)); draft.setUpdatedAt(new Date()); } }