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 下沉共享)。
|
*
|
* <p>住在 {@code jnpf-audit-entity} 而不是某一层的实现包里,是因为它产出的正是
|
* {@code audit_events.field_diffs} 这一列的形状:两层各写一份就必然漂移,而
|
* {@code field_diffs} 一旦跨层不同构,查询侧与前端就得按层分支渲染,审计数据也不再可比。
|
*
|
* <p>与 lims 既有的 {@code LimsFieldDiff} 是**同一套输出形状**({@code VisualLogModel[]}:
|
* field / fieldName / oldData / newData / type),前端渲染可以直接复用;区别在于本类面对的是
|
* 「两张按列名/字段名索引的 Map」而非实体类:
|
* <ul>
|
* <li>比较的是**数据库列**(层 1,列全集来自 {@code SELECT *})或**表单字段**(层 0,
|
* 来自在线表单事件的 newData),新加的列/字段自动纳入;</li>
|
* <li>{@code fieldName} 默认回落成列名,可由 {@code labels} 参数补中文标签。
|
* <b>标签的来源必须是元数据而不是手写配置</b>——66 张 lims 表手写要上千条、且必然与表结构
|
* 漂移,那是「看起来做完了、其实没有」的典型。层 0 的标签取自在线表单事件自带的
|
* {@code listLog}(表单模型元数据,随表单发布自动更新);层 1 暂无元数据来源,
|
* 故传 null 走列名回落。</li>
|
* </ul>
|
*
|
* <h3>为什么要做类型归一(L006 的层 1 版本)</h3>
|
* <p>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 毫秒比、字节数组按内容比。
|
*
|
* <h3>脱敏</h3>
|
* <p>命中脱敏列(层 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() {
|
}
|
|
/**
|
* 单个字段的**展示元数据**(不是值)。来源必须是表单模型元数据,不是手写配置。
|
*
|
* <p>D1 门禁查出:新 {@code field_diffs} 比 lims 旧 {@code data_log} 少四个键,
|
* 而**每一个都有前端消费方**(`FormExtraPanel/DataLogList.vue` 的四条渲染分支)。
|
* 本类补上其中两个——它们与已在用的 {@code fieldName} 同源同性质(都取自事件自带的
|
* {@code listLog},是**元数据不是值**,故不受 L003「平台 oldData 不可信」约束):
|
*
|
* <ul>
|
* <li>{@link #nameModified}——<b>这一个是有实际危害的那条</b>。前端在它为 true 时
|
* 只渲染「已修改」、**不打印值**。缺了它,字典/动态字段会把**存储原值**
|
* (雪花 ID / 字典码)当明文渲染出来:既没有可读性,又把不该直接示人的原值摆到了页面上。
|
* D1 实测旧数据 318 个 diff 项中 <b>63 项</b>为 true(select 42 + userSelect 21)。</li>
|
* <li>{@link #jnpfKey}——控件类型,驱动分支渲染:{@code sign}/{@code signature} 渲染成
|
* 可点开的旧/新签名图片预览,其余走明文新旧值。缺了它签名会退化成一串 URL 文本。</li>
|
* </ul>
|
*
|
* <h3>子表({@code chidData} / {@code chidField})由调用方追加</h3>
|
* <p>这两个键同样在 {@code listLog} 里,但不是普通标量字段:
|
* <ul>
|
* <li>普通字段仍由本类比较主表快照;子表必须按子表主键匹配行,不能把整段 JSON 当标量。</li>
|
* <li>visualdev 在业务写入前后读取数据库,并用原生日志算法生成完整的
|
* {@code chidData/chidField};统一审计监听器校验两者同时存在后原样追加。</li>
|
* <li>旧事件或非标准发布方缺少可信 {@code listLog} 时,调用方继续写
|
* {@code extra.subTableUndecidable},不构造伪差异。</li>
|
* </ul>
|
* <p>本类只承载子表控件元数据;行级结构由监听器追加,避免破坏层 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String> values = optionValues(raw);
|
if (values.isEmpty()) {
|
return null;
|
}
|
List<String> 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<String> 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<String> values = new ArrayList<>();
|
for (Object value : (Collection<Object>) 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<String, Object> before, Map<String, Object> after, Set<String> masked) {
|
return diff(before, after, masked, null);
|
}
|
|
/**
|
* 计算 before → after 的字段级 diff,并用 {@code meta} 补展示元数据。
|
*
|
* @param meta 字段名 → {@link FieldMeta}(中文标签 / 控件类型 / 是否只显示「已修改」)。
|
* null 或查不到的字段:{@code fieldName} 回落成字段名本身,
|
* {@code jnpfKey}/{@code nameModified} 两个键**整个不出现**。
|
* <p><b>「查不到就不出这个键」是刻意的,不是省事</b>:层 1 没有元数据来源
|
* (它面对的是数据库列,没有表单模型),恒传 null。若无条件补上
|
* {@code "jnpfKey":null} 之类,层 1 的 190 条既有断言会因形状变化而红,
|
* 而它的语义一点没变——那是纯粹的自伤。缺席即「本层给不出」,
|
* 前端按 key 存在与否分支,天然兼容。
|
* <p>元数据**不参与比较、不影响是否记录**,所以它缺失最坏只是显示得糙一点,
|
* 绝不会让一处真实变更消失。
|
*/
|
public static String diff(Map<String, Object> before, Map<String, Object> after,
|
Set<String> masked, Map<String, FieldMeta> meta) {
|
List<Map<String, Object>> 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<String, Object> 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<String, FieldMeta> 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<String, Object> row, Set<String> masked) {
|
return snapshot(row, masked, null);
|
}
|
|
/** 整行快照,额外使用字段元数据识别非固定列名的签名控件。 */
|
public static String snapshot(Map<String, Object> row, Set<String> masked,
|
Map<String, FieldMeta> meta) {
|
if (row == null) {
|
return null;
|
}
|
Map<String, Object> out = new LinkedHashMap<>();
|
for (Map.Entry<String, Object> 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<String> unionColumns(Map<String, Object> before, Map<String, Object> after) {
|
Set<String> 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<String> masked) {
|
return masked != null && column != null && masked.contains(column.toLowerCase(Locale.ROOT));
|
}
|
|
/**
|
* 归一化比较。**顺序有讲究**:先处理 null,再按「数值 / 时间 / 字节数组」三类归一,
|
* 最后才退回 equals——反过来写会让 {@code BigDecimal("1.0")} 与 {@code 1} 判成不等。
|
*
|
* <p><b>整段包 try(B3 评审 I-1)</b>:比较单个值时抛出的异常曾能穿出 {@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();
|
}
|
}
|