刘光辉
昨天 bb638871a7fb692d80f1b7a758f991dc0879002c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
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();
    }
}