package jnpf.audit.diff; import com.alibaba.fastjson.JSON; import jnpf.util.JsonUtil; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; /** * 审计事件的泛化字段 diff——**层 0 与层 1 共用的数据契约**(B3 建立,C1 下沉共享)。 * *

住在 {@code jnpf-audit-entity} 而不是某一层的实现包里,是因为它产出的正是 * {@code audit_events.field_diffs} 这一列的形状:两层各写一份就必然漂移,而 * {@code field_diffs} 一旦跨层不同构,查询侧与前端就得按层分支渲染,审计数据也不再可比。 * *

与 lims 既有的 {@code LimsFieldDiff} 是**同一套输出形状**({@code VisualLogModel[]}: * field / fieldName / oldData / newData / type),前端渲染可以直接复用;区别在于本类面对的是 * 「两张按列名/字段名索引的 Map」而非实体类: *

* *

为什么要做类型归一(L006 的层 1 版本)

*

before-image 与 after-image 虽然都来自 JDBC,但**同一列在两次查询里可能拿到不同的 Java 类型** * ——最典型的是数值列({@code BigDecimal} vs {@code Integer} vs {@code Long},取决于驱动与列定义) * 与时间列({@code Timestamp} vs {@code Date})。直接 {@code equals} 会把「没改过」判成「改了」, * 产出一堆假 diff,审计数据的可信度就毁了。故:数值一律按 {@link BigDecimal#compareTo} 比、 * 时间一律按 epoch 毫秒比、字节数组按内容比。 * *

脱敏

*

命中脱敏列(层 1 来自 {@code AuditTableRegistry#maskedColumns},层 0 来自 Nacos 表单注册表) * 时**只记「已变更」不记值**—— * 新旧值都写占位符。注意占位符要**成对出现**:只脱敏新值会让旧值把秘密漏出去。 */ public final class AuditFieldDiff { /** 脱敏占位符:前端直接展示这个串,语义是「变了,但内容不予记录」。 */ public static final String MASKED = "***"; /** 前端按 {@code jnpfKey=='table'} 渲染子表行级变更入口。 */ public static final String JNPF_KEY_TABLE = "table"; private AuditFieldDiff() { } /** * 单个字段的**展示元数据**(不是值)。来源必须是表单模型元数据,不是手写配置。 * *

D1 门禁查出:新 {@code field_diffs} 比 lims 旧 {@code data_log} 少四个键, * 而**每一个都有前端消费方**(`FormExtraPanel/DataLogList.vue` 的四条渲染分支)。 * 本类补上其中两个——它们与已在用的 {@code fieldName} 同源同性质(都取自事件自带的 * {@code listLog},是**元数据不是值**,故不受 L003「平台 oldData 不可信」约束): * *

* *

子表({@code chidData} / {@code chidField})由调用方追加

*

这两个键同样在 {@code listLog} 里,但不是普通标量字段: *

*

本类只承载子表控件元数据;行级结构由监听器追加,避免破坏层 1 的数据库列 diff 契约。 */ public static final class FieldMeta { private final String label; private final String jnpfKey; private final Boolean nameModified; private final String valueType; private final String componentType; private final String format; private final Map optionLabels; private final String dictionaryType; private final Boolean hidden; public FieldMeta(String label, String jnpfKey, Boolean nameModified) { this(label, jnpfKey, nameModified, null, null, null, null, null, null); } public FieldMeta(String label, String jnpfKey, Boolean nameModified, String valueType, String componentType, String format, Map optionLabels) { this(label, jnpfKey, nameModified, valueType, componentType, format, optionLabels, null, null); } public FieldMeta(String label, String jnpfKey, Boolean nameModified, String valueType, String componentType, String format, Map optionLabels, String dictionaryType, Boolean hidden) { this.label = label; this.jnpfKey = jnpfKey; this.nameModified = nameModified; this.valueType = valueType; this.componentType = componentType; this.format = format; this.optionLabels = optionLabels == null || optionLabels.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(new LinkedHashMap<>(optionLabels)); this.dictionaryType = dictionaryType; this.hidden = hidden; } public String label() { return label; } public String jnpfKey() { return jnpfKey; } public Boolean nameModified() { return nameModified; } public String valueType() { return valueType; } public String componentType() { return componentType; } public String format() { return format; } public Map optionLabels() { return optionLabels; } public String dictionaryType() { return dictionaryType; } public Boolean hidden() { return hidden; } /** 使用表单在事件发生时携带的 options 生成展示快照;无法完整解析时返回 null。 */ public String optionDisplay(String raw) { if (raw == null || raw.trim().isEmpty() || optionLabels.isEmpty()) { return raw == null || raw.trim().isEmpty() ? "" : null; } List values = optionValues(raw); if (values.isEmpty()) { return null; } List labels = new ArrayList<>(values.size()); for (String value : values) { String label = optionLabels.get(value); if (label == null) { return null; } labels.add(label); } return String.join("、", labels); } @SuppressWarnings("unchecked") private static List optionValues(String raw) { String trimmed = raw.trim(); if (!trimmed.startsWith("[")) { return Collections.singletonList(trimmed); } try { Object parsed = JSON.parse(trimmed); if (!(parsed instanceof Collection)) { return Collections.emptyList(); } List values = new ArrayList<>(); for (Object value : (Collection) parsed) { if (value != null) { values.add(String.valueOf(value)); } } return values; } catch (Throwable ignored) { return Collections.emptyList(); } } } /** * 计算 before → after 的字段级 diff(无标签来源,{@code fieldName} 回落列名)。 * * @param before before-image 行(DELETE 场景传删除前的行,after 传 null) * @param after after-image 行(INSERT 场景传插入后的行,before 传 null) * @param masked 脱敏列(小写列名) * @return {@code VisualLogModel[]} 形状的 JSON 字符串;无差异时返回 {@code "[]"}(不返回 null) */ public static String diff(Map before, Map after, Set masked) { return diff(before, after, masked, null); } /** * 计算 before → after 的字段级 diff,并用 {@code meta} 补展示元数据。 * * @param meta 字段名 → {@link FieldMeta}(中文标签 / 控件类型 / 是否只显示「已修改」)。 * null 或查不到的字段:{@code fieldName} 回落成字段名本身, * {@code jnpfKey}/{@code nameModified} 两个键**整个不出现**。 *

「查不到就不出这个键」是刻意的,不是省事:层 1 没有元数据来源 * (它面对的是数据库列,没有表单模型),恒传 null。若无条件补上 * {@code "jnpfKey":null} 之类,层 1 的 190 条既有断言会因形状变化而红, * 而它的语义一点没变——那是纯粹的自伤。缺席即「本层给不出」, * 前端按 key 存在与否分支,天然兼容。 *

元数据**不参与比较、不影响是否记录**,所以它缺失最坏只是显示得糙一点, * 绝不会让一处真实变更消失。 */ public static String diff(Map before, Map after, Set masked, Map meta) { List> items = new ArrayList<>(); for (String column : unionColumns(before, after)) { Object oldValue = before == null ? null : before.get(column); Object newValue = after == null ? null : after.get(column); if (valuesEqual(oldValue, newValue)) { continue; } FieldMeta fm = fieldMeta(meta, column); boolean hide = isMasked(column, masked) || isCredentialMetadata(fm); Map item = new LinkedHashMap<>(); item.put("field", column); String label = fm == null ? null : fm.label(); item.put("fieldName", label == null || label.isEmpty() ? column : label); // 顺序对齐 lims 旧 VisualLogModel,方便与旧 data_log 逐项对照;前端按 key 取,不依赖顺序。 if (fm != null && fm.jnpfKey() != null && !fm.jnpfKey().isEmpty()) { item.put("jnpfKey", fm.jnpfKey()); } String oldData = hide ? MASKED : stringify(oldValue); String newData = hide ? MASKED : stringify(newValue); item.put("oldData", oldData); item.put("newData", newData); if (fm != null && fm.valueType() != null) { item.put("valueType", fm.valueType()); } if (fm != null && fm.componentType() != null) { item.put("componentType", fm.componentType()); } if (fm != null && fm.format() != null) { item.put("format", fm.format()); } if (fm != null && fm.dictionaryType() != null) { item.put("dictionaryType", fm.dictionaryType()); } if (!hide && fm != null) { String oldDisplay = fm.optionDisplay(oldData); String newDisplay = fm.optionDisplay(newData); if (oldDisplay != null) { item.put("oldDisplay", oldDisplay); } if (newDisplay != null) { item.put("newDisplay", newDisplay); } } // nameModified=true 时前端只渲染「已修改」不打印值。**脱敏列上照样带**—— // 两者不冲突:脱敏管的是「值不记」,nameModified 管的是「值不展示」, // 同时成立时前端显示「已修改」而不是 `***`,语义更准。 if (fm != null && fm.nameModified() != null) { item.put("nameModified", fm.nameModified()); } // 与 LimsFieldDiff 同义:0=从无到有(新增),1=修改。 // **脱敏列恒记 1**(B5 评审 M-3):type=0 等于告诉读者「这列此前没有值」—— // 内容没漏,存在性漏了半格(「初始密码是第一次设置」本身就是信息)。 // 脱敏的承诺是「只记已变更」,那就连这半格也不给。 item.put("type", !hide && isEmpty(oldValue) ? 0 : 1); items.add(item); } return JsonUtil.getObjectToString(items); } /** * 表单 vModel 保留 camelCase,而 PostgreSQL 未加引号的列名会折叠为小写。 * 元数据注册表统一存小写键,因此先精确匹配,再按小写键回退。 */ private static FieldMeta fieldMeta(Map meta, String column) { if (meta == null || column == null) { return null; } FieldMeta exact = meta.get(column); return exact != null ? exact : meta.get(column.toLowerCase(Locale.ROOT)); } /** * 整行快照(落 {@code data_snapshot})。DELETE 用 before、其余用 after—— * 快照的意义是「这次操作之后这行长什么样」,删除则是「删掉的是什么」。 */ public static String snapshot(Map row, Set masked) { return snapshot(row, masked, null); } /** 整行快照,额外使用字段元数据识别非固定列名的签名控件。 */ public static String snapshot(Map row, Set masked, Map meta) { if (row == null) { return null; } Map out = new LinkedHashMap<>(); for (Map.Entry entry : row.entrySet()) { String column = entry.getKey(); FieldMeta fm = fieldMeta(meta, column); out.put(column, isMasked(column, masked) || isCredentialMetadata(fm) ? MASKED : stringify(entry.getValue())); } return JsonUtil.getObjectToString(out); } /** 签名控件的脱敏由元数据决定,不依赖字段名约定。 */ public static boolean isCredentialMetadata(FieldMeta meta) { if (meta == null) { return false; } String valueType = lower(meta.valueType()); String componentType = lower(meta.componentType()); String jnpfKey = lower(meta.jnpfKey()); return "masked".equals(valueType) || "sign".equals(componentType) || "signature".equals(componentType) || "sign".equals(jnpfKey) || "signature".equals(jnpfKey); } private static String lower(String value) { return value == null ? null : value.trim().toLowerCase(Locale.ROOT); } /** after 的列在前、before 独有的列补在后:输出顺序对人读 diff 更友好,且稳定可断言。 */ private static Set unionColumns(Map before, Map after) { Set columns = new LinkedHashSet<>(); if (after != null) { columns.addAll(after.keySet()); } if (before != null) { columns.addAll(before.keySet()); } return columns; } private static boolean isMasked(String column, Set masked) { return masked != null && column != null && masked.contains(column.toLowerCase(Locale.ROOT)); } /** * 归一化比较。**顺序有讲究**:先处理 null,再按「数值 / 时间 / 字节数组」三类归一, * 最后才退回 equals——反过来写会让 {@code BigDecimal("1.0")} 与 {@code 1} 判成不等。 * *

整段包 try(B3 评审 I-1):比较单个值时抛出的异常曾能穿出 {@code diff()}, * 被 emitter 的总兜底吃掉,结果是**该行连同其后所有行的事件全部静默消失**—— * 漏记真实变更比记错更严重。现在退化成「按不相等处理」:最坏是多记一条字段变更, * 不会让整条事件消失。 */ public static boolean valuesEqual(Object a, Object b) { try { if (isEmpty(a) && isEmpty(b)) { return true; } if (a == null || b == null) { return a == null && b == null; } if (a instanceof Number && b instanceof Number) { return numbersEqual((Number) a, (Number) b); } if (a instanceof Date && b instanceof Date) { // 用 compareTo 而非 getTime()(评审 M-3):java.sql.Timestamp 的纳秒精度不在 // getTime() 里,亚毫秒差异会被判成"没变"——那是漏记真实变更。 // Timestamp.compareTo(Date) 与 Date.compareTo 都按各自完整精度比。 return compareDates((Date) a, (Date) b); } if (a instanceof byte[] && b instanceof byte[]) { // 字节数组的 equals 是引用比较,不归一会让每次 diff 都把 BLOB 列报成「已变更」 return Arrays.equals((byte[]) a, (byte[]) b); } return Objects.equals(a, b); } catch (Throwable t) { return false; // 比较不了就当"变了":宁可多记一条,也不让整条事件消失 } } /** * 数值比较。**先挡住非有限值**(NaN / ±Infinity):它们是 PG float8/real 的合法值, * 而 {@code BigDecimal.valueOf(NaN)} 直接抛 {@code NumberFormatException}(评审 I-1 实证, * 连 {@code NaN vs NaN} 都炸)。非有限值一律退回 {@code Double.compare} 的语义—— * 它把 {@code NaN == NaN} 判为相等、{@code NaN != 1.0} 判为不等,正是审计想要的。 */ private static boolean numbersEqual(Number a, Number b) { if (isNonFinite(a) || isNonFinite(b)) { return Double.compare(a.doubleValue(), b.doubleValue()) == 0; } return toBigDecimal(a).compareTo(toBigDecimal(b)) == 0; } private static boolean isNonFinite(Number number) { if (number instanceof Double || number instanceof Float) { double value = number.doubleValue(); return Double.isNaN(value) || Double.isInfinite(value); } return false; } /** {@code Timestamp} 与 {@code Date} 混比时以 Timestamp 一侧的完整精度为准。 */ private static boolean compareDates(Date a, Date b) { if (a instanceof java.sql.Timestamp || b instanceof java.sql.Timestamp) { java.sql.Timestamp left = a instanceof java.sql.Timestamp ? (java.sql.Timestamp) a : new java.sql.Timestamp(a.getTime()); java.sql.Timestamp right = b instanceof java.sql.Timestamp ? (java.sql.Timestamp) b : new java.sql.Timestamp(b.getTime()); return left.compareTo(right) == 0; } return a.getTime() == b.getTime(); } private static BigDecimal toBigDecimal(Number number) { if (number instanceof BigDecimal) { return (BigDecimal) number; } if (number instanceof Double || number instanceof Float) { return BigDecimal.valueOf(number.doubleValue()); } if (number instanceof java.math.BigInteger) { return new BigDecimal((java.math.BigInteger) number); // 别经 longValue() 截断 } return BigDecimal.valueOf(number.longValue()); } /** 展示用字符串。null → 空串(与 LimsFieldDiff 一致,前端按空串判「无值」)。 */ public static String stringify(Object value) { if (value == null) { return ""; } if (value instanceof Date) { return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format((Date) value); } if (value instanceof byte[]) { return "byte[" + ((byte[]) value).length + "]"; // 不把二进制塞进审计文本 } return String.valueOf(value); } private static boolean isEmpty(Object value) { if (value == null) { return true; } if (value instanceof CharSequence) { return value.toString().trim().isEmpty(); } if (value instanceof Collection) { return ((Collection) value).isEmpty(); } return value instanceof Map && ((Map) value).isEmpty(); } }