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」而非实体类:
*
* - 比较的是**数据库列**(层 1,列全集来自 {@code SELECT *})或**表单字段**(层 0,
* 来自在线表单事件的 newData),新加的列/字段自动纳入;
* - {@code fieldName} 默认回落成列名,可由 {@code labels} 参数补中文标签。
* 标签的来源必须是元数据而不是手写配置——66 张 lims 表手写要上千条、且必然与表结构
* 漂移,那是「看起来做完了、其实没有」的典型。层 0 的标签取自在线表单事件自带的
* {@code listLog}(表单模型元数据,随表单发布自动更新);层 1 暂无元数据来源,
* 故传 null 走列名回落。
*
*
* 为什么要做类型归一(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 不可信」约束):
*
*
* - {@link #nameModified}——这一个是有实际危害的那条。前端在它为 true 时
* 只渲染「已修改」、**不打印值**。缺了它,字典/动态字段会把**存储原值**
* (雪花 ID / 字典码)当明文渲染出来:既没有可读性,又把不该直接示人的原值摆到了页面上。
* D1 实测旧数据 318 个 diff 项中 63 项为 true(select 42 + userSelect 21)。
* - {@link #jnpfKey}——控件类型,驱动分支渲染:{@code sign}/{@code signature} 渲染成
* 可点开的旧/新签名图片预览,其余走明文新旧值。缺了它签名会退化成一串 URL 文本。
*
*
* 子表({@code chidData} / {@code chidField})由调用方追加
* 这两个键同样在 {@code listLog} 里,但不是普通标量字段:
*
* - 普通字段仍由本类比较主表快照;子表必须按子表主键匹配行,不能把整段 JSON 当标量。
* - visualdev 在业务写入前后读取数据库,并用原生日志算法生成完整的
* {@code chidData/chidField};统一审计监听器校验两者同时存在后原样追加。
* - 旧事件或非标准发布方缺少可信 {@code listLog} 时,调用方继续写
* {@code extra.subTableUndecidable},不构造伪差异。
*
* 本类只承载子表控件元数据;行级结构由监听器追加,避免破坏层 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