package jnpf.flowable.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jnpf.dmsBusinessOccupancy.BusinessFileEligibilityService.Operation; import jnpf.dmsBusinessOccupancy.BusinessFileSubmissionService; import jnpf.flowable.mapper.DmsLifecycleNativeFormMapper; import jnpf.exception.WorkFlowException; import jnpf.util.UserProvider; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.*; /** Only native launch/relaunch, before engine start. Approval writes do not call this method. */ @Service public class DmsLifecycleSubmissionGuard implements jnpf.flowable.model.filepermission.DmsRevisionAttachmentCheck { @org.springframework.beans.factory.annotation.Autowired private jnpf.file.FileContentApi fileContentApi; private static final ObjectMapper JSON = new ObjectMapper(); // Only approved original versions and immutable DMS edit copies may reuse a comparison. // Ordinary file IDs can be overwritten by legacy editors and are deliberately not cached. private final Map contentChecks = Collections.synchronizedMap(new LinkedHashMap<>(128, .75f, true) { @Override protected boolean removeEldestEntry(Map.Entry entry) { return size() > 2048; } }); private final DmsLifecycleNativeFormMapper mapper; private final BusinessFileSubmissionService submissions; private final jnpf.dmsBusinessOccupancy.BusinessRevisionSubmissionService revisions; private final boolean enabled; private final DmsLifecycleNativeFormAdapter nativeForms; public DmsLifecycleSubmissionGuard(DmsLifecycleNativeFormMapper mapper, SqlSessionTemplate sessions, DmsLifecycleNativeFormAdapter nativeForms, @Value("${dms.business-occupancy.enabled:false}") boolean enabled) { this.mapper=mapper;this.enabled=enabled;this.nativeForms=nativeForms; revisions=enabled ? new jnpf.dmsBusinessOccupancy.BusinessRevisionSubmissionService(sessions) : null; submissions=enabled ? new BusinessFileSubmissionService(sessions) : null; } /** Native launch/relaunch entry; the result freezes DMS identity before the engine is started. */ public boolean beforeSave(String formId,String taskId,Integer status,Map data) throws WorkFlowException { if(!enabled || (!Integer.valueOf(0).equals(status) && !Integer.valueOf(1).equals(status))) return false; var form=mapper.publishedLifecycleForm(formId); if(form==null) return false; try { nativeForms.select(formId,null,true); } catch (RuntimeException failure) { throw new WorkFlowException(failure.getMessage()); } Operation operation=switch(form.getMainTable()) { case "dms_wenjian_xiuding_shenqing" -> Operation.REVISION; case "dms_wenjian_chexiao_tiyi" -> Operation.REVOCATION; case "dms_wenjian_shixiao_shenqing" -> Operation.INVALIDATION; case "wenjian_fushen_yanqi_shenqing" -> Operation.EXTENSION; default -> null; }; try { String tenant=UserProvider.getUser().getTenantId(); tenant=tenant==null || tenant.isBlank() ? "0" : tenant; var ids=mapper.applicationIds(tenant,taskId,form.getMainTable()); if(ids.size()>1) throw new IllegalStateException("流程任务关联多个生命周期申请,不能选择任意一条"); // Native non-incrementing forms initially persist the server-generated task ID as main ID. JsonNode config=JSON.readTree(form.getFormData()); if(config.path("primaryKeyPolicy").asInt(1)!=1) throw new IllegalStateException("生命周期申请需要原生非自增主键,不能猜测业务主键"); String applicationId=ids.isEmpty()?taskId:ids.get(0); if("dms_wenjian_xinzeng_shenhe".equals(form.getMainTable())) { String numberId=string(data.get("wenjian_bianhao_id")); // Empty initial drafts remain valid; once a number is selected its use is atomic // with native persistence and the existing name-change callback's row lock. String number=numberId==null && status==0 ? null : submissions.guardCreationNumber( tenant,numberId,string(data.get("wenjian_mingcheng"))); if(status==0 && !submissions.hasOccupancy(tenant,taskId)) return true; if(number==null || !number.equals(string(data.get("wenjian_bianhao")))) throw new IllegalArgumentException("新增审核编号与已发放编号不一致"); List numbers=new ArrayList<>();numbers.add(number); String recordKey=detailKey(config.path("fields"),"dms_wenjian_xinzeng_shenhe_jilu_mingxi"); Object records=recordKey==null ? null : data.get(recordKey); if(records!=null && !(records instanceof List)) throw new IllegalArgumentException("新增审核记录明细格式错误"); if(records instanceof List list) for(Object raw:list) { if(!(raw instanceof Map row)) throw new IllegalArgumentException("新增审核记录明细格式错误"); String change=string(row.get("biangeng_fangshi")); if(change!=null && !"xinjian".equals(change)) throw new IllegalArgumentException("新增记录只能选择新建"); numbers.add(string(row.get("fujian_bianhao"))); } submissions.submitCreation(tenant,applicationId,taskId,numbers,status==0); return true; } if("dms_wenjian_xiuding_shenhe".equals(form.getMainTable())) { if(status==0 && !revisions.hasSubmission(tenant,applicationId,taskId)) return true; String recordKey=detailKey(config.path("fields"),"dms_wenjian_xiuding_shenhe_jilu_mingxi"); if(recordKey==null) throw new IllegalArgumentException("修订审核缺少已发布的原生记录子表配置"); Object records=data.get(recordKey); if(!(records instanceof List list)) throw new IllegalArgumentException("修订审核必须提交完整记录明细,允许空数组"); List targets=new ArrayList<>(); for(Object raw:list) { if(!(raw instanceof Map row)) throw new IllegalArgumentException("修订记录明细格式错误"); var target=new jnpf.dmsBusinessOccupancy.BusinessRevisionRecord(); target.setFileId(string(row.get("jilu_wenjian_id"))); target.setBusinessVersionId(string(row.get("yuan_wenjian_banben_id"))); target.setFileNumber(string(row.get("fujian_bianhao"))); target.setChange(string(row.get("biangeng_fangshi")));targets.add(target); } revisions.submit(tenant,applicationId,taskId,string(data.get("xiuding_shenqing_zixiang_id")), string(data.get("biangeng_fangshi")),targets,status==0); if(status==1) data.put(REQUIRED, true); return true; } if(operation==null) throw new IllegalArgumentException("未知生命周期申请类型"); String key=detailKey(config.path("fields"),form.getMainTable()+"_zixiang"); Object rows=key==null || data==null ? null : data.get(key); List targets=new ArrayList<>(); if(rows instanceof List list) for(Object raw:list) { if(!(raw instanceof Map row)) throw new IllegalArgumentException("生命周期申请明细格式错误"); String versionKey=operation==Operation.REVISION?"yuan_wenjian_banben_id":"wenjian_banben_id"; String file=string(row.get("wenjian_id")); String version=operation==Operation.EXTENSION?null:string(row.get(versionKey)); if(file==null || (operation!=Operation.EXTENSION && version==null)) { if(status==0) continue; // never-submitted incomplete draft is checked by service ownership throw new IllegalArgumentException("文件或来源版本缺失,请重新选择"); } targets.add(new BusinessFileSubmissionService.Target(file,version)); } submissions.submit(tenant,taskId,applicationId,taskId,operation,targets,status==0); return true; } catch(jnpf.dmsBusinessOccupancy.BusinessFileIneligibleException failure) { String tenant=UserProvider.getUser().getTenantId(); tenant=tenant==null || tenant.isBlank() ? "0" : tenant; throw new WorkFlowException(DmsLifecycleEligibilityMessages.render(mapper,tenant,failure)); } catch(Exception failure) { throw new WorkFlowException(failure.getMessage()); } } @Override public void validate(String formId, Map data) { var form = mapper.publishedLifecycleForm(formId); if (form == null || !"dms_wenjian_xiuding_shenhe".equals(form.getMainTable())) return; try { String key = detailKey(JSON.readTree(form.getFormData()).path("fields"), "dms_wenjian_xiuding_shenhe_jilu_mingxi"); if (key == null || !(data.get(key) instanceof List records)) throw new IllegalArgumentException("修订审核必须提交完整记录明细"); String tenant = UserProvider.getUser().getTenantId(); validateRevisionAttachments(tenant == null || tenant.isBlank() ? "0" : tenant, data, records); } catch (java.io.IOException failure) { throw new IllegalArgumentException("修订审核配置无效"); } } // Runs on canonical adopted attachments inside native save, after occupancy acquisition. void validateRevisionAttachments(String tenant, Map data, List records) { if ("wubianhua".equals(string(data.get("biangeng_fangshi")))) requireUnchangedAttachment(mapper.revisionMainAttachment(tenant, string(data.get("xiuding_shenqing_zixiang_id"))), data.get("wenjian"), "主文件", tenant); int index=0; for(Object raw:records) { index++; if(!(raw instanceof Map row)) throw new IllegalArgumentException("修订记录明细格式错误"); if("wubianhua".equals(string(row.get("biangeng_fangshi")))) requireUnchangedAttachment(mapper.revisionAttachment(tenant,string(row.get("jilu_wenjian_id")), string(row.get("yuan_wenjian_banben_id"))),row.get("jilu_wenjian"),"第"+index+"行记录", tenant); } } private void requireUnchangedAttachment(String original,Object submitted,String label, String tenant) { String cacheKey = comparisonKey(tenant, original, submitted); Boolean equal = cacheKey == null ? null : contentChecks.get(cacheKey); if (equal == null) { equal = jnpf.file.FileContentEquality.same(original, submitted, fileContentApi); if (cacheKey != null) contentChecks.put(cacheKey, equal); } if (equal) return; throw new IllegalArgumentException(label+"选择了无变化,但附件与原版本不一致,请恢复原附件或将变更方式改为升级"); } private static String comparisonKey(String tenant, String original, Object submitted) { try { JsonNode before = JSON.readTree(original); JsonNode after = submitted instanceof String text ? JSON.readTree(text) : JSON.valueToTree(submitted); if (!before.isArray() || before.isEmpty() || !after.isArray() || after.isEmpty() || before.size() != after.size()) return null; List identity = new ArrayList<>(); identity.add("dms-original-byte-equality-v1"); identity.add(tenant); for (JsonNode file : before) { if (file.path("fileId").asText().isBlank() || file.path("url").asText().isBlank()) return null; identity.add(Arrays.asList(file.path("fileId").asText(), file.path("url").asText())); } for (JsonNode file : after) { if (!file.path("fileId").asText().matches("onlyoffice-edit-temp,[a-f0-9]{32},(?:review_)?[a-f0-9]{32},[^,]+") || file.path("url").asText().isBlank()) return null; identity.add(Arrays.asList(file.path("fileId").asText(), file.path("url").asText())); } return cn.hutool.crypto.digest.DigestUtil.sha256Hex(JSON.writeValueAsString(identity)); } catch (Exception invalid) { return null; } } private static String string(Object value) { return value instanceof String s && !s.isBlank() ? s : null; } private static String detailKey(JsonNode fields,String table) { for(JsonNode field:fields) { JsonNode config=field.path("__config__"); if("table".equals(config.path("jnpfKey").asText()) && table.equals(config.path("tableName").asText())) return field.path("__vModel__").asText(); String child=detailKey(config.path("children"),table);if(child!=null)return child; } return null; } }