package jnpf.base.service; import cn.hutool.core.util.IdUtil; import cn.hutool.crypto.digest.DigestUtil; import jnpf.base.ActionResult; import jnpf.base.ActionResultCode; import jnpf.base.model.uploadedit.*; import jnpf.dms.DmsRevisionEditApi; import jnpf.dmsEntity.storage.DmsRevisionEditRequest; import jnpf.exception.DataException; import jnpf.util.JsonUtil; import jnpf.util.TicketUtil; import jnpf.util.UserProvider; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import java.util.*; import java.util.concurrent.TimeUnit; /** DMS新增/修订审核共用临时副本;只保存附件结果,主子表仍由原生表单持久化。 */ @Service @RequiredArgsConstructor public class DmsAuditTemporaryEditService { private final UploadEditAccess access; private final DmsRevisionEditApi sources; private final StringRedisTemplate redis; private static final DefaultRedisScript CAS = new DefaultRedisScript<>( "if redis.call('get',KEYS[1]) == ARGV[1] then redis.call('set',KEYS[1],ARGV[2],'KEEPTTL'); return 1 else return 0 end",Long.class); /** 上传/模板凭据已经由原临时入口核验,转换成同一审核副本会话。 */ public Map beginUpload(UploadEditCommand command, Map file) { UploadEditContext context = command.getContext(); if (!access.isDmsAuditTemporary(context)) throw denied(); Map proof = new LinkedHashMap<>(); proof.put("purpose", "dms-audit-upload"); proof.put("tenantId", UploadEditDraftService.tenant(UserProvider.getUser())); proof.put("ownerId", UserProvider.getLoginUserId()); proof.put("modelId", context.getModelId()); proof.put("parentField", Objects.toString(context.getParentField(), "")); proof.put("field", context.getField()); proof.put("file", new LinkedHashMap<>(file)); proof.put("expiresAt", System.currentTimeMillis()+14400000L); String receipt = TicketUtil.createTicket(JsonUtil.getObjectToString(proof), 14400L); Map source = new LinkedHashMap<>(file); source.put("revisionEditReceipt", receipt); UploadEditCommand issued = new UploadEditCommand(); issued.setContext(context); issued.setFileId(command.getFileId()); issued.setFiles(List.of(source)); return begin(issued); } public Map begin(UploadEditCommand command) { UploadEditContext context=command.getContext(); // 不把已保存记录、流程实例或可跟踪子行降级为临时副本。 if (context==null || text(context.getRecordId()) || text(context.getTaskId())) throw denied(); String owner=UserProvider.getLoginUserId(), tenant=UploadEditDraftService.tenant(UserProvider.getUser()); access.authorizeTemporary(context,owner); if (command.getFiles()==null || command.getFiles().size()!=1) throw denied(); String receipt=Objects.toString(command.getFiles().get(0).get("revisionEditReceipt"),""); Map proof=verify(receipt,context,tenant,owner); Map file=(Map)proof.get("file"); if (!Objects.equals(command.getFileId(),file.get("fileId")) || !jnpf.base.util.UploadEditStateMachine.supportsEditing(file.get("name"))) throw denied(); UploadEditLease lease=new UploadEditLease(); lease.setLeaseId(IdUtil.simpleUUID()); lease.setDraftId(lease.getLeaseId()); lease.setTenantId(tenant); lease.setOwnerId(owner); lease.setRevisionTemporary(true); Map issuedFile=new LinkedHashMap<>(file); issuedFile.put("revisionEditReceipt",receipt); lease.setFile(issuedFile); Map state=new LinkedHashMap<>(); state.put("lease",lease); state.put("context",context); state.put("receipt",receipt); long remaining=((Number)proof.get("expiresAt")).longValue()-System.currentTimeMillis(); if (remaining<=0) throw denied(); redis.opsForValue().set(key(tenant,lease.getLeaseId()),JsonUtil.getObjectToString(state),remaining,TimeUnit.MILLISECONDS); return response(lease); } public UploadEditLease find(String tenant,String owner,String id) { String raw=redis.opsForValue().get(key(tenant,id)); if (raw==null) return null; return checked(raw,tenant,owner,id); } public boolean canReadStatus(String tenant,String owner,String id) { String raw=redis.opsForValue().get(key(tenant,id)); if (raw==null) return false; Map state=JsonUtil.getJsonToBean(raw,Map.class); UploadEditLease lease=JsonUtil.getJsonToBean(state.get("lease"),UploadEditLease.class); return Objects.equals(owner,lease.getOwnerId()) && Objects.equals(tenant,lease.getTenantId()) && Objects.equals(id,lease.getLeaseId()); } private UploadEditLease checked(String raw,String tenant,String owner,String id) { Map state=JsonUtil.getJsonToBean(raw,Map.class); UploadEditLease lease=JsonUtil.getJsonToBean(state.get("lease"),UploadEditLease.class); if (!lease.isRevisionTemporary() || !Objects.equals(tenant,lease.getTenantId()) || !Objects.equals(owner,lease.getOwnerId()) || !Objects.equals(id,lease.getLeaseId())) throw denied(); UploadEditContext context=JsonUtil.getJsonToBean(state.get("context"),UploadEditContext.class); access.authorizeTemporary(context,owner); verify(Objects.toString(state.get("receipt"),""),context,tenant,owner); lease.setAllowDownload(access.allowDownload(context)); lease.setQianpiyeAnnotatorEnabled(access.qianpiyeAnnotatorEnabled(context)); return lease; } public boolean save(UploadEditSaveCommand command) { String key=key(command.getTenantId(),command.getLeaseId()); String raw=redis.opsForValue().get(key); if (raw==null) return false; UploadEditLease lease=checked(raw,command.getTenantId(),command.getOwnerId(),command.getLeaseId()); if (Objects.equals(command.getOperationId(),lease.getLastOperationId())) return true; if (!text(command.getOperationId()) || command.getFile()==null || !Objects.toString(command.getFile().get("fileId"),"").contains(","+lease.getLeaseId()+",")) throw denied(); Map state=JsonUtil.getJsonToBean(raw,Map.class); UploadEditContext context=JsonUtil.getJsonToBean(state.get("context"),UploadEditContext.class); Map proof=new LinkedHashMap<>(verify(Objects.toString(state.get("receipt"),""),context,lease.getTenantId(),lease.getOwnerId())); Map file=new LinkedHashMap<>(); for(String field:List.of("fileId","name","fileSize","fileExtension","url")) file.put(field,command.getFile().get(field)); if (!jnpf.base.util.UploadEditStateMachine.supportsEditing(file.get("name"))) throw denied(); Object savedAt = command.getFile().get("contentSavedAt"); jnpf.base.util.UploadTemplateSource.inherit(file, lease.getFile()); if (savedAt instanceof Number) file.put("contentSavedAt", savedAt); proof.put("file",new LinkedHashMap<>(file)); long remaining=(((Number)proof.get("expiresAt")).longValue()-System.currentTimeMillis())/1000; if (remaining<=0) throw denied(); String receipt=TicketUtil.createTicket(JsonUtil.getObjectToString(proof),remaining); file.put("revisionEditReceipt",receipt); lease.setFile(file); lease.setLastOperationId(command.getOperationId()); state.put("lease",lease); state.put("receipt",receipt); if (!Long.valueOf(1).equals(redis.execute(CAS,List.of(key),raw,JsonUtil.getObjectToString(state)))) { TicketUtil.deleteTicket(receipt); return false; } return true; } /** 原生落库之前校验带入来源并移除仅供未保存页面使用的凭据。 */ public void prepareSave(jnpf.base.model.flow.DataModel model) { Map data = model.getDataNewMap(); String tenant = UploadEditDraftService.tenant(UserProvider.getUser()); String owner = UserProvider.getLoginUserId(); UploadEditContext mainContext = new UploadEditContext(); mainContext.setModelId(model.getVisualId()); mainContext.setField("wenjian"); if (data.containsKey("wenjian") && access.isDmsAuditTemporary(mainContext)) { Object original = data.get("wenjian"); List> mainFiles = jnpf.base.util.UploadEditStateMachine.files(original); // 修订凭据由下面的原生保存校验核实;普通上传不能携带任意模板标记。 List> ordinary = mainFiles.stream() .filter(file -> !file.containsKey("revisionEditReceipt")).toList(); jnpf.base.util.UploadTemplateSource.normalize(ordinary, List.of(), tenant, owner); data.put("wenjian", original instanceof String ? JsonUtil.getObjectToString(mainFiles) : mainFiles); } sanitizeFiles(data, "wenjian", "", data, model.getVisualId(), tenant, owner); Object rows = data.get("jilu_mingxi"); if (rows instanceof List) for (Object row : (List) rows) { if (row instanceof Map) sanitizeFiles((Map) row, "jilu_wenjian", "jilu_mingxi", data, model.getVisualId(), tenant, owner); } } private void sanitizeFiles(Map row, String field, String parent, Map data, String modelId, String tenant, String owner) { Object original = row.get(field); if (original == null || !(original instanceof List || original instanceof String)) return; boolean hasReceipt = original instanceof String ? ((String) original).contains("\"revisionEditReceipt\"") : ((List) original).stream().anyMatch(value -> value instanceof Map && ((Map) value).containsKey("revisionEditReceipt")); if (!hasReceipt) return; // 无修订凭据的普通业务字段保持原值与原生保存路径。 List> files = jnpf.base.util.UploadEditStateMachine.files(original); if (files.stream().noneMatch(file -> file.containsKey("revisionEditReceipt"))) return; List> clean = new ArrayList<>(); for (Map file : files) { if (!file.containsKey("revisionEditReceipt")) { clean.add(file); continue; } UploadEditContext context = new UploadEditContext(); context.setModelId(modelId); context.setField(field); context.setParentField(parent); Map proof = verify(Objects.toString(file.get("revisionEditReceipt"), ""), context, tenant, owner); Map canonical = (Map) proof.get("file"); if (!"dms-audit-upload".equals(proof.get("purpose")) && (!Objects.equals(proof.get("sourceItemId"), data.get("xiuding_shenqing_zixiang_id")) || !Objects.equals(proof.get("versionId"), row.get("yuan_wenjian_banben_id")))) throw denied(); if (!Objects.equals(canonical.get("fileId"), file.get("fileId"))) throw denied(); // 仅内部来源核验服务可返回原版本完整附件;新上传和编辑副本仍采用原规范值。 if ("dms-revision-edit".equals(proof.get("purpose")) && proof.get("sourceFileJson") instanceof String) { canonical = JsonUtil.getJsonToBean((String)proof.get("sourceFileJson"), Map.class); if (!Objects.equals(canonical.get("fileId"), file.get("fileId")) || !Objects.equals(canonical.get("fileId"), proof.get("originFileId"))) throw denied(); } clean.add(new LinkedHashMap<>(canonical)); } row.put(field, original instanceof String ? JsonUtil.getObjectToString(clean) : clean); } public Map result(String id) { String tenant=UploadEditDraftService.tenant(UserProvider.getUser()),owner=UserProvider.getLoginUserId(); UploadEditLease lease=find(tenant,owner,id); if (lease==null) throw denied(); return response(lease); } public void cleanup(String tenant,String owner,String id) { String key=key(tenant,id),raw=redis.opsForValue().get(key); if (raw==null) return; Map state=JsonUtil.getJsonToBean(raw,Map.class); UploadEditLease lease=JsonUtil.getJsonToBean(state.get("lease"),UploadEditLease.class); if (!Objects.equals(owner,lease.getOwnerId())) throw denied(); // 不删除附件实体:原生表单可能已经采用。删除会话阻止后续回调重新写入。 redis.delete(key); } private Map verify(String receipt,UploadEditContext context,String tenant,String owner) { String raw = text(receipt) ? TicketUtil.parseTicket(receipt) : null; Map upload = raw == null ? Collections.emptyMap() : JsonUtil.getJsonToBean(raw, Map.class); if ("dms-audit-upload".equals(upload.get("purpose"))) { if (!Objects.equals(tenant, upload.get("tenantId")) || !Objects.equals(owner, upload.get("ownerId")) || !Objects.equals(context.getModelId(), upload.get("modelId")) || !Objects.equals(context.getField(), upload.get("field")) || !Objects.toString(context.getParentField(), "").equals(upload.get("parentField")) || !(upload.get("expiresAt") instanceof Number) || ((Number)upload.get("expiresAt")).longValue() <= System.currentTimeMillis() || !(upload.get("file") instanceof Map) || !access.isDmsAuditTemporary(context)) throw denied(); return upload; } DmsRevisionEditRequest request=new DmsRevisionEditRequest(); request.setReceipt(receipt);request.setTenantId(tenant);request.setOwnerId(owner);request.setModelId(context.getModelId()); request.setField(context.getField());request.setParentField(context.getParentField()); ActionResult> result=sources.verify(request); if (result==null || !ActionResultCode.SUCCESS.getCode().equals(result.getCode()) || result.getData()==null) throw denied(); return result.getData(); } private static Map response(UploadEditLease lease) { Map result=new LinkedHashMap<>();result.put("leaseId",lease.getLeaseId());result.put("draftId",lease.getDraftId()); result.put("source",lease.getFile());result.put("files",List.of(lease.getFile()));result.put("revision",0); result.put("canDiscard",false);result.put("revisionTemporary",true);return result; } private static String key(String tenant,String id) { if (!text(tenant) || id==null || !id.matches("[a-f0-9]{32}")) throw denied(); return "upload-edit:dms-revision:"+DigestUtil.sha256Hex(tenant)+":"+id; } private static boolean text(String value) { return value!=null && !value.isBlank(); } private static DataException denied() { return new DataException("附件编辑授权已失效,请重新选择来源或上传附件"); } }