package jnpf.dmsService; import jnpf.dmsEntity.center.DmsCenterVersionSnapshot; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.node.ObjectNode; import jnpf.dmsEntity.*; import jnpf.dmsEntity.storage.approval.*; import jnpf.dmsMapper.*; import jnpf.exception.DataException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.*; /** Reads the complete native source; never accepts a client-provided subset of records. */ @Component @RequiredArgsConstructor public class DmsApprovedAuditSource { // Persisted approval hashes are an internal contract, independent of HTTP JSON settings. // Retain the existing UTC ISO dates, camelCase fields, nulls and compact JSON bytes. private static final ObjectMapper SOURCE_JSON = new ObjectMapper() .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .setTimeZone(TimeZone.getTimeZone("UTC")); private final DmsWenjianXinzengShenheMapper newMapper; private final DmsWailaiWenjianXinzengShenheMapper externalMapper; private final DmsWenjianXiudingShenheMapper revisionMapper; private final DmsWenjianXinzengShenheJiluMingxiMapper newRecords; private final DmsWenjianXiudingShenheJiluMingxiMapper revisionRecords; private final DmsWenjianXiudingShenqingZixiangMapper revisionDetails; private final DmsWenjianXiudingShenqingMapper revisionRequests; private final DmsWenjianGuanxiMapper existingRelations; private final DmsWenjianBianhaoMapper numbers; private final DmsWenjianBanbenMapper businessVersions; private final jnpf.dmsService.center.DmsCenterQueryService centerVersions; private final DmsWenjianGuanlianMingxiMapper relations; private final ObjectMapper json; private final DmsDisableApplicationGuard disableApplications; private final DmsXiudingKanwuService revisionErrata; public static final class Source { private final DmsApprovedBatch batch; private final String preparedBy; private final String reviewerIds; private final Date preparedOn; private final boolean settled; private final List errata; public Source(DmsApprovedBatch batch, String preparedBy, Date preparedOn, boolean settled) { this(batch, preparedBy, preparedOn, settled, Collections.emptyList()); } public Source(DmsApprovedBatch batch, String preparedBy, Date preparedOn, boolean settled, List errata) { this(batch, preparedBy, preparedOn, settled, errata, null); } public Source(DmsApprovedBatch batch, String preparedBy, Date preparedOn, boolean settled, List errata, String reviewerIds) { this.reviewerIds = reviewerIds; this.batch = batch; this.preparedBy = preparedBy; this.preparedOn = preparedOn; this.settled = settled; this.errata = errata; } public DmsApprovedBatch batch() { return batch; } public String reviewerIds() { return reviewerIds; } public String preparedBy() { return preparedBy; } public Date preparedOn() { return preparedOn; } public boolean settled() { return settled; } public List errata() { return errata; } } public Source load(String tenant, String flow, String task, String type, String actor, Date approvedAt) { if ("wailai_wenjian_xinzeng_shenhe".equals(type)) return loadExternal(tenant, flow, task, actor, approvedAt); Object audit; List records; String baseId = null; String baseFileId = null; String documentNo = null; String approvalPageFileJson = null; String numberSnapshot = null; Date effective, expires, created; String creator; String reviewerIds; if ("xinzeng_shenhe".equals(type)) { DmsWenjianXinzengShenheEntity a = unique(newMapper.selectByFlowForUpdate(tenant, flow, task)); audit = a; reviewerIds = a.getShenheRenId(); records = newRecords.selectByAuditForUpdate(tenant, a.getId()); DmsWenjianBianhaoEntity n = numbers.selectActiveByIdForUpdate(tenant, a.getWenjianBianhaoId()); if (n == null || !"using".equals(n.getBizStatus()) || n.getDangqianWenjianMingcheng() == null || !Objects.equals(n.getWenjianBianhao(), a.getWenjianBianhao())) throw new DataException("审核文件编号与已发放编号不一致"); documentNo = n.getWenjianBianhao(); approvalPageFileJson = approvalPageFileJson(n); numberSnapshot = n.getGuizeKuaizhao(); effective = a.getShengxiaoRiqi(); expires = a.getYouxiaoQiZhi(); creator = a.getCreatorUserId(); created = a.getCreatorTime(); } else if ("xiuding_shenhe".equals(type)) { DmsWenjianXiudingShenheEntity a = unique(revisionMapper.selectByFlowForUpdate(tenant, flow, task)); audit = a; reviewerIds = a.getShenheRenId(); records = revisionRecords.selectByAuditForUpdate(tenant, a.getId()); DmsWenjianXiudingShenqingZixiangEntity detail = revisionDetails.selectByIdForUpdate(tenant, a.getXiudingShenqingZixiangId()); if (detail == null) throw new DataException("修订申请子项不存在"); DmsWenjianXiudingShenqingEntity request = revisionRequests.selectByIdForUpdate(tenant, detail.getForeignId()); if (request == null || !Integer.valueOf(2).equals(request.getFlowState())) throw new DataException("修订申请尚未审批通过"); if ("occupied".equals(detail.getBizStatus()) && !a.getId().equals(detail.getZhanYongDanjuId())) throw new DataException("修订申请子项已被其他审核单占用"); baseId = detail.getYuanWenjianBanbenId(); baseFileId = detail.getWenjianId(); disableApplications.requireFileEditable(tenant, baseFileId); effective = a.getShengxiaoRiqi(); expires = a.getYouxiaoQiZhi(); creator = a.getCreatorUserId() != null ? a.getCreatorUserId() : request.getCreatorUserId(); created = a.getCreatorTime() != null ? a.getCreatorTime() : request.getCreatorTime(); } else throw new DataException("审核类型无效"); ObjectNode main = SOURCE_JSON.valueToTree(audit); if (main.path("flowState").asInt(-1) != 2) throw new DataException("原生审核单尚未保存批准状态"); if ("yes".equals(main.path("shifouPeixun").asText()) && !main.hasNonNull("peixunWanchengRiqi")) throw new DataException("需要培训的文件尚未完成培训"); if (creator == null || created == null) throw new DataException("原生审核单缺少起草人或起草时间"); String auditId = text(main, "id"); List errata = "xiuding_shenhe".equals(type) ? revisionErrata.loadAndValidate(tenant, auditId, baseFileId, baseId) : Collections.emptyList(); DmsApprovedBatch batch = new DmsApprovedBatch(); batch.setTenantId(tenant); batch.setFlowId(flow); batch.setTaskId(task); batch.setAuditId(auditId); batch.setAuditType(type); batch.setActorId(actor); batch.setApprovedAt(approvedAt); batch.setScheduledAt(DmsApprovedFileDates.scheduled(effective)); batch.setEffectiveTo(DmsApprovedFileDates.expires(expires, batch.getScheduledAt())); List items = new ArrayList<>(); String prefix = type + ":" + auditId + ":"; DmsApprovedItem mainItem = item(tenant, prefix + "zhuwenjian", null, main, true, "xinzeng_shenhe".equals(type) ? "xinjian" : text(main, "biangengFangshi"), baseId, baseFileId, documentNo); if ("REVISE".equals(mainItem.getChangeType())) { approvalPageFileJson = revisionApprovalPageFileJson(tenant, baseId, baseFileId); } mainItem.setApprovalPageFileJson(approvalPageFileJson); items.add(mainItem); copySignaturePage(numberSnapshot, mainItem); // Use the already validated source for both the legacy and the new physical-import paths. mainItem.setQianpiyeFiles(approvalPageFileJson); List sourceRows = new ArrayList<>(); if (records != null) for (Object row : records) sourceRows.add(SOURCE_JSON.valueToTree(row)); sourceRows.sort(Comparator.comparing(row -> text(row, "id"))); for (JsonNode row : sourceRows) { if (!Objects.equals(text(main, "wenjianMuluId"), text(row, "wenjianMuluId"))) throw new DataException("记录文件目录必须与主文件一致"); String change = "xinzeng_shenhe".equals(type) ? "xinjian" : text(row, "biangengFangshi"); if ("xinzeng_shenhe".equals(type) && row.hasNonNull("biangengFangshi") && !row.path("biangengFangshi").asText().isBlank() && !"xinjian".equals(row.path("biangengFangshi").asText())) throw new DataException("新增记录只能选择新建"); items.add(item(tenant, prefix + "jilu:" + text(row, "id"), text(row, "id"), row, false, change, row.path("yuanWenjianBanbenId").asText(null), row.path("jiluWenjianId").asText(null), row.path("fujianBianhao").asText(null))); } List related = "xinzeng_shenhe".equals(type) ? relations.selectNewAuditRelations(tenant, auditId) : relations.selectRevisionAuditRelations(tenant, auditId); if (baseFileId != null) { Set recordIds = new HashSet<>(); sourceRows.forEach(row -> { if (row.hasNonNull("jiluWenjianId")) recordIds.add(row.path("jiluWenjianId").asText()); }); appendRemovedRecords(tenant, baseFileId, recordIds, prefix, items); Set relatedIds = new HashSet<>(); if (related != null) for (DmsWenjianGuanlianMingxiEntity relation : related) { if (!relatedIds.add(relation.getGuanlianWenjianId())) throw new DataException("审核单包含重复关联文件"); } requireRetained(tenant, baseFileId, "guanlian_wenjian", relatedIds); } if (items.stream().map(DmsApprovedItem::getSourceKey).distinct().count() != items.size()) throw new DataException("审核来源包含重复记录"); if (items.stream().filter(i -> i.getBaseFileVersionId() != null).map(DmsApprovedItem::getBaseFileVersionId).distinct().count() != items.stream().filter(i -> i.getBaseFileVersionId() != null).count()) throw new DataException("审核单不能重复使用同一来源文件"); // Exclude only result fields written by settlement. All exposed native inputs participate. boolean settled = main.hasNonNull("shengxiaoWenjianBanbenId"); scrub(main); omitEmptyReadScopeFromDigest(main); main.remove(Arrays.asList("wenjianId", "yuanWenjianBanbenId")); sourceRows.forEach(row -> { scrub((ObjectNode) row); omitEmptyReadScopeFromDigest((ObjectNode) row); if ("xinzeng_shenhe".equals(type) || "xinjian".equals(row.path("biangengFangshi").asText())) ((ObjectNode) row).remove("jiluWenjianId"); }); List digestInputs = new ArrayList<>(Arrays.asList(main, sourceRows, related, baseId, baseFileId)); List removals = new ArrayList<>(); items.stream().filter(i -> "RETIRE".equals(i.getChangeType())).forEach(i -> removals.add(Arrays.asList(i.getFileId(), i.getBusinessVersionId(), i.getDocumentId(), i.getFileVersionId()))); if (!removals.isEmpty()) digestInputs.add(removals); if (approvalPageFileJson != null) digestInputs.add(approvalPageFileJson); // Preserve historic hashes when no errata exist; new handling decisions are approval inputs. if (!errata.isEmpty()) digestInputs.add(errata); batch.setSourceDigest(digest(digestInputs)); batch.setItems(items); return new Source(batch, creator, created, settled, errata, reviewerIds); } private Source loadExternal(String tenant, String flow, String task, String actor, Date approvedAt) { DmsWailaiWenjianXinzengShenheEntity audit = unique(externalMapper.selectByFlowForUpdate(tenant, flow, task)); if (!Integer.valueOf(2).equals(audit.getFlowState())) throw new DataException("外来文件审核单尚未批准"); if (audit.getShenqingRen() == null || audit.getShenqingRen().isBlank() || audit.getShenqingRiqi() == null) throw new DataException("外来文件审核单缺少申请人或申请日期"); String version = requireExternal(audit.getBanbenhao(), "版本号"); String fileJson = requireExternal(audit.getBizFiles(), "附件"); try { JsonNode files = json.readTree(fileJson); if (files == null || !files.isArray() || files.size() != 1 || !files.get(0).isObject()) throw new DataException("外来文件审核单必须包含一份附件"); } catch (java.io.IOException error) { throw new DataException("外来文件附件格式无效"); } String folder = requireExternal(audit.getWenjianMuluId(), "文件目录"); try { UUID.fromString(folder); } catch (IllegalArgumentException error) { throw new DataException("外来文件目录不是有效的中心目录ID"); } DmsApprovedItem item = new DmsApprovedItem(); item.setTenantId(tenant); item.setSourceKey("wailai_wenjian_xinzeng_shenhe:" + audit.getId() + ":wailaiWenjian"); item.setChangeType("CREATE"); item.setDocumentNo(requireExternal(audit.getWailaiWenjianBianhao(), "文件编号")); item.setDocumentName(requireExternal(audit.getWenjianMingcheng(), "文件名称")); item.setDepartmentId(requireExternal(audit.getBumenId(), "归属部门")); item.setFolderId(folder); item.setVersionLabel(version); item.setFileJson(fileJson); item.setShifouShoukong("yes"); DmsApprovedBatch batch = new DmsApprovedBatch(); batch.setTenantId(tenant); batch.setFlowId(flow); batch.setTaskId(task); batch.setAuditId(audit.getId()); batch.setAuditType("wailai_wenjian_xinzeng_shenhe"); batch.setActorId(actor); batch.setApprovedAt(approvedAt); batch.setScheduledAt(DmsApprovedFileDates.scheduled(approvedAt)); ObjectNode source = SOURCE_JSON.valueToTree(audit); source.remove(Arrays.asList("wenjianId", "shengxiaoWenjianBanbenId", "banbenShengxiaoShijian")); batch.setSourceDigest(digest(source)); batch.setItems(List.of(item)); return new Source(batch, audit.getShenqingRen(), audit.getShenqingRiqi(), audit.getShengxiaoWenjianBanbenId() != null); } private static String requireExternal(String value, String name) { if (value == null || value.isBlank()) throw new DataException("外来文件审核单缺少" + name); return value.trim(); } private void copySignaturePage(String snapshot, DmsApprovedItem main) { if (snapshot == null || snapshot.isBlank()) return; try { JsonNode rule = json.readTree(snapshot); if (!rule.isObject()) throw new DataException("编号规则快照格式不正确"); main.setQianpiyeId(optionalSnapshotText(rule, "qianpiye_id")); main.setQianpiyeFiles(optionalSnapshotText(rule, "qianpiye_files")); } catch (DataException e) { throw e; } catch (Exception e) { throw new DataException("无法读取编号规则签批页快照"); } } private String optionalSnapshotText(JsonNode rule, String field) { JsonNode value = rule.get(field); if (value == null || value.isNull()) return null; if (!value.isTextual()) throw new DataException("编号规则快照字段必须为文本:" + field); return value.textValue(); } private DmsApprovedItem item(String tenant, String key, String rowId, JsonNode row, boolean main, String change, String baseId, String baseFileId, String no) { DmsApprovedItem item = new DmsApprovedItem(); item.setTenantId(tenant); item.setSourceKey(key); item.setSourceRowId(rowId); item.setReadAccessScope(DmsApprovedDepartmentScope.normalize(row.path("chayueShouquanFanweiId").asText(null))); item.setDocumentName(text(row, main ? "wenjianMingcheng" : "fujianMingcheng")); String fileJson = row.path(main ? "wenjian" : "jiluWenjian").asText(null); if ((fileJson == null || fileJson.isBlank()) && !"wubianhua".equals(change)) throw new DataException("审核来源缺少文件"); item.setFileJson(fileJson); item.setDepartmentId(text(row, "guishuBumenId")); item.setFolderId(text(row, "wenjianMuluId")); try { UUID.fromString(item.getFolderId()); } catch (IllegalArgumentException e) { throw new DataException("文件目录不是有效的中心目录ID"); } item.setDocumentNo(no); if (main) item.setShifouShoukong("yes"); if (!main) { // Audit records choose their control flag directly; legacy grade metadata is optional. String controlled = text(row, "shifouShoukong"); if (!"yes".equals(controlled) && !"no".equals(controlled)) throw new DataException("记录受控属性无效"); item.setShifouShoukong(controlled); if ("xinjian".equals(change) && row.hasNonNull("banbenhao") && !row.path("banbenhao").asText().isBlank() && !"D(00)".equals(row.path("banbenhao").asText())) throw new DataException("新增记录版本必须为 D(00)"); } switch (change) { case "xinjian": item.setChangeType("CREATE"); item.setVersionLabel("D(00)"); break; case "shengji": case "wubianhua": { disableApplications.requireFileEditable(tenant, baseFileId); DmsWenjianBanbenEntity base = businessVersions.selectById(tenant, baseId); if (base == null || !Objects.equals(base.getWenjianId(), baseFileId) || base.getWenjianZhongxinBanbenId() == null) throw new DataException("来源业务版本尚未关联文件中心,不能自动升级或沿用"); DmsCenterVersionSnapshot center = centerVersions.version(tenant, UUID.fromString(base.getWenjianZhongxinBanbenId())); if (center == null) throw new DataException("来源中心版本不存在"); item.setDocumentId(center.documentId()); item.setBaseFileVersionId(center.fileVersionId()); item.setChangeType("wubianhua".equals(change) ? "REUSE" : "REVISE"); item.setVersionLabel("REUSE".equals(item.getChangeType()) ? center.versionLabel() : String.format(java.util.Locale.ROOT, "D(%02d)", Math.addExact(base.getBanbenXuhao(), 1))); if ("REUSE".equals(item.getChangeType())) { if (item.getFileJson() != null && !item.getFileJson().isBlank() && !sameContent(base.getWenjianNeirong(), item.getFileJson())) throw new DataException("沿用版本不能修改文件内容"); item.setFileJson(base.getWenjianNeirong()); item.setFileVersionId(center.fileVersionId()); item.setChecksumSha256(center.checksumSha256()); } } break; default: throw new DataException("文件变更方式无效"); } if ("CREATE".equals(item.getChangeType()) && (no == null || no.isBlank())) throw new DataException("文件编号缺失"); return item; } private void appendRemovedRecords(String tenant, String mainFileId, Set retained, String prefix, List items) { List existing = existingRelations.selectEffectiveByMainAndType( tenant, mainFileId, DmsWenjianYewuBanbenService.RELATION_RECORD); if (existing == null) return; existing.stream().filter(r -> !retained.contains(r.getGuanlianWenjianId())) .sorted(Comparator.comparing(DmsWenjianGuanxiEntity::getGuanlianWenjianId)).forEach(relation -> { String fileId = relation.getGuanlianWenjianId(); disableApplications.requireFileEditable(tenant, fileId); DmsWenjianBanbenEntity base = businessVersions.selectCurrentByFileId(tenant, fileId); if (base == null || !DmsWenjianYewuBanbenService.RECORD_FILE.equals(base.getWenjianLeixing()) || !"effective".equals(base.getBizStatus()) || base.getWenjianZhongxinBanbenId() == null) throw new DataException("移除记录缺少有效业务版本或中心关联"); DmsCenterVersionSnapshot center = centerVersions.version(tenant, UUID.fromString(base.getWenjianZhongxinBanbenId())); if (center == null) throw new DataException("移除记录的中心版本不存在"); DmsApprovedItem item = new DmsApprovedItem(); item.setTenantId(tenant); item.setSourceKey(prefix + "removed:" + fileId); item.setChangeType("RETIRE"); item.setFileId(fileId); item.setBusinessVersionId(base.getId()); item.setDocumentId(center.documentId()); item.setFileVersionId(center.fileVersionId()); item.setBaseFileVersionId(center.fileVersionId()); item.setChecksumSha256(center.checksumSha256()); items.add(item); }); } private void requireRetained(String tenant, String fileId, String type, Set retained) { List existing = existingRelations.selectEffectiveByMainAndType(tenant, fileId, type); if (existing != null) for (DmsWenjianGuanxiEntity row : existing) { if (!retained.contains(row.getGuanlianWenjianId())) throw new DataException("现行记录或关联文件不能从修订审核中删除"); } } private void scrub(ObjectNode value) { value.remove(Arrays.asList("shengxiaoWenjianBanbenId", "banbenShengxiaoShijian", "banbenhao")); } private String approvalPageFileJson(DmsWenjianBianhaoEntity number) { String snapshot = number.getGuizeKuaizhao(); if (snapshot == null || snapshot.isBlank()) return null; try { JsonNode value = json.readTree(snapshot).get("qianpiye_files"); if (value == null || value.isNull()) return null; String fileJson = value.isTextual() ? value.asText().trim() : value.toString(); if (fileJson.isEmpty()) return null; JsonNode files = json.readTree(fileJson); if (files == null || files.isNull() || (files.isArray() && files.isEmpty())) return null; return fileJson; } catch (Exception exception) { throw new DataException("文件编号规则签批页快照格式不正确"); } } private String revisionApprovalPageFileJson(String tenant, String baseId, String baseFileId) { DmsWenjianBanbenEntity base = businessVersions.selectById(tenant, baseId); if (base == null || !Objects.equals(base.getWenjianId(), baseFileId)) { throw new DataException("来源业务版本不存在,无法读取签批页规则"); } String numberId = base.getWenjianBianhaoId(); if (numberId == null || numberId.isBlank()) return null; DmsWenjianBianhaoEntity number = numbers.selectActiveByIdForUpdate(tenant, numberId); if (number == null || !"using".equals(number.getBizStatus()) || !Objects.equals(number.getWenjianBianhao(), base.getWenjianBianhao())) { return null; } return approvalPageFileJson(number); } // Empty scope has one business meaning. Omitting it only from the digest preserves pre-field approvals // after migration fills their native fields with []; non-empty scopes remain protected inputs. private void omitEmptyReadScopeFromDigest(ObjectNode row) { if (DmsApprovedDepartmentScope.parse(row.path("chayueShouquanFanweiId").asText(null)).isEmpty()) row.remove("chayueShouquanFanweiId"); } private String digest(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") .digest(SOURCE_JSON.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new DataException("无法计算批准来源摘要"); } } @org.springframework.beans.factory.annotation.Autowired private jnpf.file.FileContentApi fileContentApi; private boolean sameContent(String left, String right) { return jnpf.file.FileContentEquality.same(left, right, fileContentApi); } private static String text(JsonNode value, String field) { String result = value.path(field).asText(null); if (result == null || result.isBlank()) throw new DataException("审核来源缺少字段:" + field); return result.trim(); } private static T unique(List rows) { if (rows == null || rows.size() != 1) throw new DataException("必须匹配唯一的原生审核单"); return rows.get(0); } }