刘光辉
15 小时以前 34981c30a78e8bbd7791131059a9210f9928b62c
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
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
package jnpf.bizcommon.audit.service.listener;
 
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import com.alibaba.fastjson.JSON;
import jnpf.audit.AuditApiConsts;
import jnpf.audit.AuditOperationIds;
import jnpf.audit.model.AuditEventDTO;
import jnpf.audit.AuditConsts;
import jnpf.bizcommon.audit.entity.AuditEventEntity;
import jnpf.audit.diff.AuditFieldDiff;
import jnpf.bizcommon.audit.mapper.AuditEventMapper;
import jnpf.bizcommon.audit.service.AuditEventService;
import jnpf.bizcommon.audit.service.AuditFieldDiffViewService;
import jnpf.bizcommon.audit.service.AuditFormRegistry;
import jnpf.bizcommon.audit.service.entry.AuditEntryResolver;
import jnpf.bizcommon.audit.service.sign.AuditSignEvidence;
import jnpf.bizcommon.audit.service.sign.AuditSignEvidenceReader;
import jnpf.bizcommon.audit.service.sign.AuditSignMetaData;
import jnpf.bizcommon.audit.service.title.AuditRecordTitle;
import jnpf.bizcommon.audit.service.title.AuditRecordTitleBuilder;
import jnpf.bizcommon.audit.service.title.AuditTitleSpec;
import jnpf.constant.JnpfConst;
import jnpf.event.ProjectEventListener;
import jnpf.module.ProjectEventInstance;
import jnpf.util.JsonUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
 
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
 
/**
 * 层 0:在线表单变更事件 → {@code audit_events}(Task C1)。
 *
 * <p>jnpf-visualdev 在 {@code VisualLogServiceImpl.createEventLog} 以 BROADCASTING 发布
 * {@code VSLOG_EVENT_KEY} 事件(平台补丁 #1),经 RocketMQ 跨 JVM 到达本服务。本类是 lims 侧
 * {@code BaseVisualLogListener} 的迁入版,与它<b>同时订阅</b>——存储天然隔离(新写 audit_events、
 * 旧写 lims_biz_log),影子期两条链路互不干扰。
 *
 * <h3>三条防御是照着实测教训写的,改动前先读 tasks/lessons.md</h3>
 * <ul>
 *   <li><b>L002</b>:整个 handler 包 try/catch(Throwable)。JNPF 的 {@code ProjectEventProccess}
 *       是 for-iterator 分发,任一 consumer 抛异常会 break 整个循环——同 channel 上的其它
 *       listener(含 lims 旧 listener)会跟着一起哑掉。审计绝不能拖垮别人。</li>
 *   <li><b>L003</b>:UPDATE 使用 visualdev 在数据库写入前、写入后分别查询得到的
 *       {@code oldData}/{@code newData}。两份数据必须随同一事件固化,MQ 消费端不回读业务表,
 *       避免异步延迟读到后续操作产生的未来状态。</li>
 *   <li><b>L004</b>:跨 JVM 后 source 是 {@code JSONObject},当 Map 取。
 *       {@code VisualLogForm} 用了 {@code @Builder} 没有无参构造器,反序列化不回去。</li>
 * </ul>
 *
 * <h3>数据库写入边界快照</h3>
 * <p>每条层 0 事件仍在 {@code data_snapshot} 保存操作后的数据,但 UPDATE 的
 * {@code field_diffs} 直接比较事件中的 {@code oldData}/{@code newData}。字段只存在于新数据时,
 * 旧值按空处理并记录为新增;只存在于旧数据时,记录为清空。不再查询上一条审计日志作为基线。
 *
 * <h3>与旧 listener 的一处**刻意不同**(A/B 比对必须知道)</h3>
 * <p>旧 listener 把 {@code jianyan_liushuihao} 当硬过滤器:取不到就整条丢弃。本实现<b>不跟</b>——
 * bizCode 取不到就留空,事件照记。理由是审计的红线是「这次改过不能缺」(B3 确立),而
 * {@code jianyan_liushuihao} 是 LIMS 检验流水号,环境监测/水系统等模块的表单根本没有这个字段,
 * 按旧口径它们一条都不会被记。<b>后果</b>:同一批表单上 {@code audit_events} 会比
 * {@code lims_biz_log} 多出事件,C2 的配对率算法必须按此口径解读,否则会把「新表更全」
 * 误判成「配对失败」。
 */
@Slf4j
@Component
public class AuditVisualLogListener {
 
    /** 幂等键前缀。广播模式下每个实例都会收到同一条事件,靠 UNIQUE(client_event_id) 挡重复。 */
    private static final String EVENT_ID_PREFIX = "L0-";
 
    /** 层 0 事件的 app_name = 事实产生进程;用户视角的「在哪个应用上」由 biz_module 承担(spec §5.4)。 */
    private static final String SOURCE_APP = "jnpf-visualdev";
 
    private static final String UNKNOWN = "UNKNOWN";
 
    /**
     * 平台技术列的保留前缀。业务规范禁止业务字段以此开头(仓库 CLAUDE.md「Conventions」),
     * 故可安全按前缀剔除,不需要维护一张会漂移的列名白名单。详见 {@link #withoutTechColumns}。
     */
    private static final String TECH_COLUMN_PREFIX = "f_";
 
    /**
     * 虽带平台前缀,但它们描述的是业务记录的创建/修改责任人和时间,属于审计事实。
     * 这些字段必须进入 field_diffs,不能随其余平台技术列一起剔除。
     */
    private static final Set<String> AUDITED_PLATFORM_COLUMNS = Collections.unmodifiableSet(
            new LinkedHashSet<>(Arrays.asList(
                    "f_creator_user_id",
                    "f_creator_time",
                    "f_last_modify_user_id",
                    "f_last_modify_time")));
 
    /**
     * 签名 id 来自请求顶层(非数据字段)时,落进 {@code signExtra.signField} 的<b>展示标签</b>。
     *
     * <p>🔴 <b>它只是标签,不是判据</b>(2026-08-05 Codex 复审 High①)。此前它同时兼着
     * 「来源标记」与 {@code Map<String,String> signFieldToId} 的 <b>key</b>,于是「够不够格
     * 改写 action_code」这件事被编码在了一个<b>可碰撞的字符串</b>里:业务表只要真有一个
     * 同名列,它的数据列证据就会被当成请求来源({@code reviewFromRequest=true} → 把普通
     * UPDATE 永久写成 REVIEW),而且 {@code reordered.putAll} 会让数据列的值<b>覆盖</b>
     * 请求值。全库实查当前 0 个同名列,是埋着的陷阱不是活的故障——但 L081 的规矩正是
     * 「来源必须单独记成标记跟着值走」,用字段名编码来源等于把标记又塞回了内容里。
     * 现在来源是 {@link SignCandidate#fromRequest} 这个独立布尔量,本常量退回纯展示用途,
     * 值保持不变是为了历史数据的 {@code signExtra.signField} 语义连续。
     */
    private static final String REQUEST_SIGN_FIELD = "__request_biz_sign__";
 
    /**
     * 本类产出的签名事件的 action_code 与 source_layer。
     *
     * <p><b>抽成常量是刻意的</b>(2026-08-03 Codex 终审缺陷 2):A1-b 的历史反查必须按
     * 与写入侧**完全一样**的四元组去查(表名 + event_type + action_code + source_layer)。
     * 实测同一张 {@code lims_sign} 上,层 2 也写 {@code action_code=SIGN}、层 3 还写
     * {@code SIGN_USED}、层 2 写 {@code SIGN_FAIL}——少钉一个维度就会把别的层记的事件
     * 算成「本 listener 记过了」,真实的复核签名被判成历史、本次签名事件被静默抑制。
     * 两处分别写字面量,改一处漏一处不报任何错。
     */
    private static final String SIGN_ACTION_CODE = "SIGN";
 
    /** 本 listener 补写的签名属于显式签名事实层;反查与写入共用,理由同 {@link #SIGN_ACTION_CODE}。 */
    private static final int SIGN_SOURCE_LAYER = 3;
 
    /**
     * extra.frontendBizData 的长度上限。extra 列不在 {@code AuditEventServiceImpl.clip} 的
     * 白名单里,没有任何护栏——批量场景下 N 条事件共享同一份整批 biz_data 快照,
     * 不设上限会线性叠加到百 MB 级(见调用处注释的实测数字)。
     */
    private static final int FRONTEND_BIZ_DATA_MAX = 8192;
 
    /**
     * 请求携带的签名「多新才算新鲜」(批 D,2026-08-05)。**超出只留痕、不拒绝**。
     *
     * <p>取 30 分钟是权衡:用户点签名到点确定之间要填表、翻页、被打断,卡太紧会把正常操作
     * 误报成可疑;而重放攻击真正的克星是「一次性消费」(签过就作废)而不是时间窗——
     * 那要真加消费状态,属批 D 完整版。这里只把年龄如实摆出来给查询侧判断,
     * 所以阈值宽一点也不会掩盖什么:真要查,{@code signAuthStaleMillis} 里有确切毫秒数。
     */
    private static final long SIGN_FRESH_WINDOW_MILLIS = 30L * 60 * 1000;
 
    @Autowired
    private AuditEventService auditEventService;
 
    @Autowired
    private AuditEventMapper auditEventMapper;
 
    @Autowired
    private AuditFormRegistry registry;
 
    @Autowired
    private AuditFieldDiffViewService fieldDiffViewService;
 
    @Autowired
    private AuditRecordTitleBuilder recordTitleBuilder;
 
    @Autowired
    private AuditSignEvidenceReader signEvidenceReader;
 
    @Autowired
    private AuditEntryResolver entryResolver;
 
    @ProjectEventListener(channelRegex = JnpfConst.VSLOG_EVENT_KEY + ".*")
    public void onVisualLog(ProjectEventInstance event) {
        try {
            handle(event);
        } catch (Throwable t) {
            // L002:绝不让异常出去——同 channel 上的其它 listener 是全有或全无的命运共同体
            log.error("[AUDIT-L0] 处理 VSLOG 事件失败(业务与其它监听者不受影响)eventId={}",
                    event == null ? null : event.getEventId(), t);
        }
    }
 
    private void handle(ProjectEventInstance event) {
        if (!registry.isEnabled() || event == null) {
            return;
        }
        Map<String, Object> form = asMap(event.getSource());
        if (form == null) {
            return;
        }
        String modelId = str(form.get("modelId"));
        if (modelId == null) {
            return;
        }
        AuditFormRegistry.FormMeta meta = registry.resolve(modelId);
        if (meta == null) {
            return;   // 清单外的表单:默认不记,正常路径,不留痕(否则每次保存都刷一行日志)
        }
        // 直接读 meta.getTable(),不再调一次 resolveMainTable(评审 M-10:resolve() 内部已解析过一次)。
        // 安全性来自 AuditFormRegistry.setForms 会把 table 归一化写回 meta——两者是同一个小写表名。
        String table = meta.getTable();
        String dataId = str(form.get("dataId"));
 
        Object typeRaw = form.get("type");
        boolean create = typeRaw instanceof Number && ((Number) typeRaw).intValue() == 0;
        // type=2 是 AUDIT 平台补丁(2026-08-01)新增的删除事件,平台原生只发 0/1。
        boolean delete = typeRaw instanceof Number && ((Number) typeRaw).intValue() == 2;
        boolean read = typeRaw instanceof Number && ((Number) typeRaw).intValue() == 3;
        if (read && !registry.isRecordRead()) {
            return;
        }
        String actionCode = read ? AuditConsts.ACT_READ
                : (delete ? AuditConsts.ACT_DELETE
                : (create ? AuditConsts.ACT_CREATE : AuditConsts.ACT_UPDATE));
 
        // UPDATE 的两侧都是 visualdev 在数据库写入边界读取的快照;MQ 消费端不再查询历史审计快照。
        Map<String, Object> oldData = asMap(form.get("oldData"));
        Map<String, Object> newData = asMap(form.get("newData"));
        // 两侧统一字符串化,避免 JDBC/JSON 数值与日期类型差异制造假 diff。
        Map<String, Object> beforeRow = stringifyValues(oldData);
        Map<String, Object> eventRow = stringifyValues(newData);
        Map<String, AuditFieldDiff.FieldMeta> labels = AuditFormRegistry.mergeFieldMetadata(
                registry.resolveFieldMetadata(modelId, table), fieldMeta(form.get("listLog")));
        List<Map<String, Object>> childDiffs = childDiffs(form.get("listLog"));
        Set<String> childFields = new LinkedHashSet<>(subTableFields(labels));
        Set<String> masked = effectiveMasked(meta.getMaskedFields(), eventRow, labels);
        masked.addAll(effectiveMasked(meta.getMaskedFields(), beforeRow, labels));
 
        Map<String, Object> extra = new LinkedHashMap<>();
        // C2 的就近配对靠这两个键 join 旧表的 source_model_id / source_data_id,别改名
        extra.put("sourceModelId", modelId);
        if (dataId != null) {
            extra.put("sourceDataId", dataId);
        }
 
        AuditCtx ctx = AuditCtx.parse(form.get("auditCtx"), event.getTenantId(), extra);
        if (!registry.acceptsRequest(ctx.appScope, ctx.appCode)) {
            return;
        }
        extra.put("appScope", ctx.appScope);
        extra.put("appCode", ctx.appCode);
 
        // 操作入口:用户从哪个菜单点进来的。三种状态必须分得开(L075:「没携带」≠「不存在」):
        //   没带 header → degraded(旧前端 / 非浏览器调用 / 定时任务)
        //   带了但查不到菜单行 → unresolved(菜单被删了,但 entry_id 仍是可追溯的事实)
        //   正常 → 不写标记
        String entryType = null;
        String entryId = null;
        String entryName = null;
        if (ctx.menuId == null) {
            extra.put("entrySource", "degraded");
        } else {
            entryType = AuditEntryResolver.TYPE_MENU;
            entryId = ctx.menuId;
            entryName = entryResolver.resolveMenuName(ctx.menuId);
            if (entryName == null) {
                extra.put("entrySource", "unresolved");
            }
        }
 
        if (read) {
            recordRead(event, form, meta, table, dataId, ctx, extra,
                    entryType, entryId, entryName);
            return;
        }
 
        String fieldDiffs = null;
        if (!childDiffs.isEmpty()) {
            extra.put("subTableDiffSource", "visualLogListLog");
        } else if (!(form.get("listLog") instanceof List) && !childFields.isEmpty()) {
            // 兼容补丁上线前或非标准发布方:没有 listLog 契约时仍如实标记不可判定。
            extra.put("subTableUndecidable", new ArrayList<>(childFields));
        }
        // 脱敏列用数据库写入前后的真实值判等,确定发生变化后再由 AuditFieldDiff 将两侧值替换为 ***。
        Map<String, Object> snapshotRow = eventRow;
        if (eventRow == null) {
            extra.put("degraded", "事件未携带 newData,本次变更内容不可得");
        } else if (delete) {
            // 删除不产 diff:没有 after 值,编出来的 diff 是假的(B3 红线)。
            // 只留 data_snapshot——「删掉的是什么」正是删除审计的全部价值。
            extra.put("snapshotSource", "eventNewData");
            extra.put("snapshotScope", "deleted");
        } else if (create) {
            // CREATE 的 before 为 null ⇒ 每一列都算新增,故技术列必须在此剔掉(见 forDiff)
            fieldDiffs = AuditFieldDiff.diff(null,
                    withoutFields(forDiff(eventRow), childFields), masked, labels);
            extra.put("snapshotSource", "eventNewData");
            // **平台把空值字段整个从 newData 里剥掉了**(2026-07-30 实测,见 knownEmptyFields 注释)。
            // 只存事件携带的键 ⇒ baseline 天生缺字段 ⇒ 那些字段**第一次被填写**时会被 UPDATE 的
            // 两侧交集排除,真实变更漏记。这里按已发布表单模型把「已知为空」显式补进快照,
            // 让「已知为空」与「baseline 真正未知」从此是两种状态、不再共用一个"缺键"表示。
            Set<String> formFields = registry.resolveBaselineFields(modelId, table);
            List<String> knownEmpty = knownEmptyFields(eventRow, formFields, masked, labels);
            snapshotRow = withKnownEmpty(eventRow, knownEmpty);
            // 补没补、补了哪些,一律留痕:补齐依赖表单元数据可读,取不到时会静默退回旧行为,
            // 而"审计有洞而无人知道"正是这套代码里反复被咬的那个坑(同 whoSource/timeSource)。
            // **按 formFields 而不是按 knownEmpty 判**:整份表单都填满了(无需补)与
            // 元数据根本取不到(补不了)是两回事,混成一个值就分辨不出快照到底完不完整。
            extra.put("baselineScope", formFields.isEmpty() ? "eventOnly" : "formModel");
            if (!knownEmpty.isEmpty()) {
                extra.put("baselineKnownEmpty", knownEmpty);
            }
        } else {
            extra.put("snapshotSource", "eventNewData");
            if (beforeRow == null) {
                extra.put("snapshotDegraded", true);
                extra.put("diffSource", "missingOldData");
            } else {
                fieldDiffs = databaseBoundaryDiff(beforeRow, eventRow, masked, labels, childFields);
                extra.put("diffSource", "databaseBeforeAfter");
            }
        }
 
        fieldDiffs = appendChildDiffs(fieldDiffs, childDiffs);
 
        if (fieldDiffs != null) {
            AuditEventEntity valueContext = new AuditEventEntity();
            valueContext.setTargetTable(table);
            valueContext.setTenantId(ctx.tenantId);
            valueContext.setEventTime(ctx.eventTime);
            valueContext.setActionCode(actionCode);
            fieldDiffs = fieldDiffViewService.enrichForPersistence(
                    valueContext, fieldDiffs, labels);
        }
 
        // ── 标题规格分流(D6)───────────────────────────────────────────────────
        // 表单操作读表单勾选,列表操作(删除)读列表勾选。前端把开关做在两个界面上,
        // 用户的心智就是「在哪勾的在哪生效」。
        // 注意:titleSpec 不止喂下面的标题渲染,也喂签名字段候选判定(见下一段注释)——
        // 删除路径的签名候选因此也会跟着改读列表勾选,而不是表单勾选。命名规则命中的
        // 凭证字段恒为候选(不看 spec,见 AuditSignEvidenceReader.candidateFields),
        // 真正受影响的只是「表单勾了 + 列表没勾 + 命名规则又不命中」这类字段:
        // 它们在删除事件里会从签名候选中掉出去。
        AuditTitleSpec titleSpec = delete
                ? registry.resolveListTitleSpec(modelId, table)
                : registry.resolveTitleSpec(modelId, table);
 
        // ── 签名字段判定(spec §5.4,D4)──────────────────────────────────────────
        // 权威来源是表单配置 confirmBtnConfig/reviewBtnConfig 的 biz_sign_field(D4)——
        // 多人签名场景下字段名可以是任意的,光靠命名规则+勾选会漏。命名规则命中的字段与
        // 勾选字段仍保留作兜底:同一个 biz_log_enabled 开关能同时服务「进标题」与「是签名」
        // 两种语义,而不需要前端再加一个开关。三条来源合并后仍要拿值去签名表撞一下,
        // 命中的才真正算签名字段(防误判的最后一关,配置不代表这次真的触发了签名)。
        List<String> configuredSignFields = registry.resolveSignFields(modelId, table);
        List<String> signCandidateFields = AuditSignEvidenceReader.candidateFields(
                eventRow, titleSpec, configuredSignFields);
        // 🔴 候选是**带来源标记的三元组列表**,不是 field→signId 的 Map(2026-08-05 Codex
        // 复审 High①,理由见 REQUEST_SIGN_FIELD 的 javadoc)。顺序即优先级,与原先
        // LinkedHashMap 的迭代序一致;来源由 SignCandidate.fromRequest 承担,与字段名解耦。
        List<SignCandidate> signCandidates = new ArrayList<>();
        Map<String, AuditSignEvidence> evidences = Collections.emptyMap();
        List<String> ids = new ArrayList<>();
        /** 请求声称携带、但在签名表里查不到的那个 id(不存在/已删/跨租户)。见剔除处的注释。 */
        String requestSignNotFound = null;
        // Task 1 平台补丁把「本次操作」的签名 id 放在事件顶层(删除路径前端只能这么传,
        // 因为被删数据里的签名字段存的是创建/编辑时的历史签名,不是这次删除的)。
        // signExtra.signField 落 REQUEST_SIGN_FIELD 这个标签,查询时能看出这条签名是
        // 「请求携带」还是「数据字段里读到的」——但判定一律走 fromRequest,不认标签。
        // **先加、排在最前**:它是本次操作最权威的签名来源,撞 MAX_IDS 上限时先保住它
        // (原先靠 reordered.putAll + ids.add(0,…) 事后重排,现在靠添加顺序天然成立)。
        String eventBizSign = str(form.get("bizSign"));
        if (eventBizSign != null && !eventBizSign.trim().isEmpty()) {
            signCandidates.add(new SignCandidate(REQUEST_SIGN_FIELD, eventBizSign.trim(), true));
            ids.add(eventBizSign.trim());
        }
        for (String field : signCandidateFields) {
            String value = AuditFieldDiff.stringify(lookup(eventRow, field)).trim();
            if (!value.isEmpty()) {
                signCandidates.add(new SignCandidate(field, value, false));
                ids.add(value);
            }
        }
        if (!ids.isEmpty()) {
            // 按租户读(批 D):f_id 单列唯一不是数据库不变量,跨租户的 signId 直接读不出来,
            // 于是候选在下面被剔掉、连签名事件都不会产出。见 read 的 javadoc。
            evidences = signEvidenceReader.read(ids, ctx.tenantId);
            // 没命中签名表的候选不是签名字段,剔回标题侧。
            Iterator<SignCandidate> pruning = signCandidates.iterator();
            while (pruning.hasNext()) {
                SignCandidate pruned = pruning.next();
                if (!evidences.containsKey(pruned.signId)) {
                    // 🔴 数据列候选落空是**正常**的(那个字段本来就不是签名字段,剔回标题侧即可);
                    // 但**请求声称携带了签名、凭证却查不到**是另一回事:不存在、已删、
                    // 或属于别的租户(D1 收窄之后跨租户的正是在这里落空)。
                    // 不留痕的话它与「请求压根没带签名」长得一模一样——等于把一次可疑的声称
                    // 洗成了「本来就没签」,而且**越高明的攻击反而越安静**(跨租户静默、
                    // 冒用有痕),方向正好反了。(L075:两种缺席不能共用一个表示。)
                    if (pruned.fromRequest) {
                        requestSignNotFound = pruned.signId;
                    }
                    pruning.remove();
                }
            }
        }
        // ── 请求携带签名的授权判定(批 D,2026-08-05,Codex 终审 High①)────────────────
        // 修复前 form.bizSign 恒为 null,这条无校验的信任路径是**死的**;是 P1a 把它接通的。
        // 接通之后,请求里塞任意一个已知的 signId 都会被当成「本次操作的签名」,而签名事件的
        // operatorId 取的是**那条凭证的主人**——等于凭一个 id 就能把「谁在何时签的名」这个
        // 事实伪造进 append-only、存十年的审计表。实测库里有 5 个用户的签名共 1306 条。
        //
        // 本层只做**审计侧**的收口(用户 2026-08-05 拍板):listener 跑在 MQ 消费端、是异步的,
        // 拦不住那次删除已经发生——但它能拒绝把伪造的归属**写进审计**,而这正是审计的本职。
        // 同步拒绝请求要改 jnpf-visualdev 控制器(平台补丁),另开一轮。
        //
        // 判据是免费的:evidence.operatorId() 就是 lims_sign.f_creator_user_id,ctx.userId 就在手边。
        for (SignCandidate candidate : signCandidates) {
            if (!candidate.fromRequest) {
                continue;   // 数据列证据不走这条路:它们本来就要过历史反查那一关
            }
            AuditSignEvidence evidence = evidences.get(candidate.signId);
            if (evidence == null) {
                continue;
            }
            String owner = evidence.operatorId();
            if (owner == null || ctx.userId == null || UNKNOWN.equals(ctx.userId)) {
                // 判不了 ≠ 判非法(L075)。auditCtx 降级(旧前端/非浏览器调用)时拿不到操作人,
                // 这时拒绝会把正常路径一起打死。**不拒绝,但如实留痕**——留痕本身就是线索:
                // 「这条签名归属没被验证过」与「验证通过了」在查询侧必须分得开。
                extra.put("signAuthUndecidable", owner == null ? "signOwnerUnknown" : "operatorUnknown");
                continue;
            }
            if (!owner.equals(ctx.userId)) {
                // 🔴 **只留痕,不拒绝**(2026-08-05 Codex 补审,用户拍板)。
                // 第一版这里判「冒用」并拒收,那是**错的**——`POST /lims/biz/sign/verify_user`
                // (LimsSignServiceImpl.verifyUserAndRecord)是「指定用户签名」的正规入口:
                // 操作人 A 在浏览器前操作,复核人 B 走过来输自己的账号密码完成复核签名,
                // 凭证主人是 B、发请求的 session 仍是 A,`allow_self != yes` 时甚至**强制**
                // 两者不同。这是标准的 GMP 多人会签,全库 1306 条签名里就有 5 个不同的主人。
                // 本仓库自己的 docs/handoff-2026-07-29-audit-query-semantics.md 早就写着:
                // 「creator_user_id …**对 verify_user 场景不能只看当前请求操作人**」。
                //
                // 那能不能补个判据把「会签」与「盗用 signId」分开?**不能**——两者在数据上
                // 长得一模一样:lims_sign 没有任何列记录「谁取得了这份凭证」,层 2 的 SIGN
                // 事件只覆盖 8/1306(0.6%)。信息不在表里,加谓词补不了(批 A 方案 B 同款形状)。
                // 硬判的代价是把一次**真实的合规会签**明文记成「伪造尝试」,而审计表 append-only
                // 洗不掉——**「错的判成了」比「没判」更坏**(L075)。
                //
                // 所以审计层的本分是**把不一致照出来,而不是拦下来**:签名事件照常产出、
                // 正确记在签名人名下(会签的复核签名不能丢),同时如实标注「签名人≠操作人」,
                // 让审计员自己看。真正的拦截属于平台层同步校验(另开一轮),
                // 根治属于批 D 完整版(服务端消费签名时显式记归属 + 一次性消费)。
                Map<String, Object> diff = new LinkedHashMap<>();
                diff.put("signField", candidate.signField);
                diff.put("signId", candidate.signId);
                diff.put("signOwner", owner);
                diff.put("operator", ctx.userId);
                extra.put("signerDiffersFromOperator", diff);
                continue;
            }
            // 陈旧签名**只留痕不拒绝**:硬时间阈值会误伤慢用户与时钟偏移,而重放的根治是
            // 「一次性消费」(签过就不能再用),那要真加消费状态——批 D 完整版的活。
            // 这里只把「这份凭证签于多久以前」如实摆出来,让查询侧自己判断。
            long ageMillis = ctx.eventTime != null && evidence.signedAt() != null
                    ? ctx.eventTime.getTime() - evidence.signedAt().getTime() : -1L;
            if (ageMillis > SIGN_FRESH_WINDOW_MILLIS) {
                extra.put("signAuthStaleMillis", ageMillis);
            }
        }
        // 「哪些字段不许进标题」——请求携带的那条也放进来(它不对应真实列,多排除一个
        // 不存在的名字无害;万一业务真有同名列,排除是安全的那个方向)。
        Set<String> signFields = new LinkedHashSet<>();
        for (SignCandidate candidate : signCandidates) {
            signFields.add(candidate.signField);
        }
 
        // 批量场景判定提前到这里:下面的 frontendBizData 膨胀防护要用,D3 的标题后缀
        // (见后文)也要用,避免同一个 form 字段读两次。
        Integer batchSize = form.get("batchSize") instanceof Number
                ? ((Number) form.get("batchSize")).intValue() : null;
 
        // 批量删除时 N 条事件共享 batchOpId 当 operationId,查询侧
        // 写入侧按 operation_id + event_category 物理合并;operation_id 代表完整前端操作。
        // clientEventId 仍按事件唯一,否则撞 uni_audit_events_client_event_id。
        // **位置上移**(2026-08-03,A1-b):下面的历史签名反查要拿它当「排除本次操作」的判据,
        // 而它原先算在 DTO 组装处。只读 form 字段,上移无副作用。
        String batchOpId = str(form.get("batchOpId"));
        // operationId 的事实源与层 2 @AuditLog 保持一致:优先使用请求线程固化的请求头。
        // 签名事件本身仍只根据 evidences 生成;关联业务日志不等于认可签名凭证。
        String correlatedBizSign = ctx.auditBizSign != null ? ctx.auditBizSign : eventBizSign;
        String correlatedOperationId = AuditOperationIds.correlation(correlatedBizSign);
        String operationId = correlatedOperationId != null ? correlatedOperationId
                : ((batchOpId != null && !batchOpId.trim().isEmpty())
                ? batchOpId.trim() : EVENT_ID_PREFIX + event.getEventId());
 
        // ── 本次操作签的 vs 行里留着的历史签名(A1-b,2026-08-03 Codex 终审 High③)──────
        // L078 立的规矩原先只在删除路径生效(P1b),非删除路径恒判「本次」。真实数据形态下
        // 这是错的(本轮实测):
        //   · 用户点一次「复核」再点「确定」会生成**两条**签名,分别落进 fuhe_sign / biz_sign;
        //   · biz_sign 列每次保存都会刷新成新生成的确定签名,所以它几乎不可能是历史;
        //   · 真正会变成历史的是 fuhe_sign 这类**只在复核那次写、之后普通保存不再更新**的列——
        //     之后每次普通保存都会把它重发一条签名事件,event_time 停在上次复核那一刻,
        //     却挂在这次保存的 operation 下。这正是 L078 骂的时间倒流,只是换到了更新路径。
        //
        // 判据取自 audit_events 而不是 lims_sign.meta_data 的 biz_data(用户 2026-08-03 拍板 D7):
        // meta_data 整块由前端 POST、后端 LimsSignServiceImpl 原样落库、零校验,拿它判定等于
        // 把「凭空产出一条签名事件」的口子交给前端报文;而「这条签名之前记过没有」这个信息
        // 本来就只存在于审计表里。也别想拿 baseline 比对签名列有没有变——快照里签名列是 `***`。
        List<Map<String, Object>> historicalSignRefs = new ArrayList<>();
        List<Map<String, Object>> signAuthRejected = new ArrayList<>();
        if (!signCandidates.isEmpty()) {
            // 删除路径**不查**:数据列候选恒判历史(P1b 规则不动),批量删 N 条一次都不查。
            Set<String> recorded = Collections.emptySet();
            if (!delete) {
                List<String> dataFieldIds = new ArrayList<>();
                for (SignCandidate candidate : signCandidates) {
                    if (!candidate.fromRequest) {
                        dataFieldIds.add(candidate.signId);
                    }
                }
                if (!dataFieldIds.isEmpty()) {
                    recorded = recordedSignIds(dataFieldIds, operationId, ctx.tenantId);
                    if (recorded == null) {
                        // D8 fail-closed:查询失败时全判历史。append-only 表里的假签名洗不掉,
                        // 比一个带留痕的已知缺口更糟;与 AuditSignEvidenceReader.read 失败时
                        // 「宁可这次不产出签名事件」同口径。signId 仍进 historicalSignRefs,
                        // 信息不丢,只是从「签名事件」降级成「可追的引用」。
                        extra.put("signHistoryDegraded", true);
                        recorded = new LinkedHashSet<>(dataFieldIds);
                    }
                }
            }
            // 判定结果**记在候选自己身上**,不再另建一个「字段名集合」——集合按名字装,
            // 而名字既可能撞车(High①)也不是候选的身份;下游两个循环直接读这个标记。
            for (SignCandidate candidate : signCandidates) {
                candidate.thisOperation = signedByThisOperation(
                        delete, candidate.fromRequest, candidate.signId, recorded);
                if (!candidate.thisOperation) {
                    historicalSignRefs.add(
                            historicalSignRef(candidate.signField, candidate.signId,
                                    evidences.get(candidate.signId)));
                }
            }
        }
        // 请求声称携带的签名压根查不到(不存在/已删/跨租户)——同样是一次「声称签了名」
        // 但拿不出凭证的事实,与冒用并列记进同一个键,让查询侧一处就能看全所有可疑声称。
        if (requestSignNotFound != null) {
            Map<String, Object> ref = new LinkedHashMap<>();
            ref.put("signField", REQUEST_SIGN_FIELD);
            ref.put("signId", requestSignNotFound);
            ref.put("reason", "signNotFound");
            ref.put("presentedBy", ctx.userId);
            signAuthRejected.add(ref);
        }
        // 被拒的凭证如实留痕:操作照旧发生了(本层异步、拦不住),但审计表里绝不写下
        // 一条「某某在此时签了名」的假事实——只记「有人提交了一份不属于自己的凭证」。
        if (!signAuthRejected.isEmpty()) {
            extra.put("signAuthRejected", signAuthRejected);
        }
        // A2(Codex 终审 Medium⑤):判为历史的候选留一份可追的引用。层 0 上线前的老数据、
        // 或当年 MQ 丢过事件的记录,行内有 biz_sign 却没有对应的 SIGN 事件——删除后快照里
        // 那一列被脱敏成 `***`,判定又把它跳过,关联就永久不可恢复了。
        // **刻意不带本次的 operation/eventTime**:这些是历史事实的引用,不是本次操作的证据
        // (L078 的规矩);signedAtMillis 是签名凭证自身的创建时刻。
        if (!historicalSignRefs.isEmpty()) {
            extra.put("historicalSignRefs", historicalSignRefs);
        }
 
        // ── 动作语义覆盖(spec D5)───────────────────────────────────────────────────
        // meta_data 唯一的不可替代价值——「用户点的是哪个按钮」。层 0 从 type 只能分出
        // CREATE/UPDATE/DELETE,分不出「复核」与「普通保存」,而复核在 GMP 语境下是
        // 必须独立追溯的动作类型。多个签名证据只认第一份(一次操作只有一个动作语义)。
        //
        // ⚠️ 按 signFieldToId 的顺序取,不按 evidences.values():evidences 是
        // `WHERE f_id IN (...)` 查回来的 Map,行序是数据库返回序,不保证等于 IN 列表顺序;
        // 删除事件可能同时命中「本次删除的 bizSign(Step 2.5 已排最前)」和「被删数据里的
        // 历史 biz_sign」两个候选,若按 evidences.values() 取第一个,选中哪份纯看数据库
        // 返回顺序,会把 Step 2.5 精心建立的优先级架空。与下面的签名事件循环同一手法
        // (signFieldToId.entrySet() + evidences.get(id)),那里已经这么写,这里跟齐。
        //
        // 🔴 删除场景不接受 meta_data 的覆盖,理由分两条,**别顺手把下面两处 delete 短路
        // "优化"掉**(评审修复轮次 1,2026-08-02,Step 2.5 落地后才暴露的新组合,brief 从未
        // 权衡过这个交叉):
        // ① action_code:Step 2.5 把签名证据喂给了删除事件之后,只要那条 lims_sign 的
        //    is_review_button=true(业务把删除二次确认配成了复核按钮),action_code 就会从
        //    DELETE 被悄悄改写成 REVIEW——查询侧是 action_code 精确匹配,这条删除记录会从
        //    「按 DELETE 筛」的结果里整个消失,直撞「不许丢掉『这次改过』」的审计红线。
        //    DELETE 是硬事实,任何 meta_data 都不能盖过它,REVIEW 覆盖必须带 !delete 短路。
        // ② action_label:biz_button 的可信度分场景——is_biz_form=true 的「复核」「确定」是
        //    平台语义、值域受控;is_biz_form=false 的「批量按钮」「列表按钮」是业务开发者在
        //    列表设计器里随手起的 UI 文案,没人审。把它当审计动作名,
        //    action_code=DELETE + action_label=批量按钮 这种组合对合规审查员而言正是
        //    actionLabel(boolean) javadoc 骂过的「既不是模块也不是动作的混合物」。
        //    所以调用处(见 dto.actionLabel(...))delete 恒显示「删除」,按钮名仍存进
        //    extra.bizButton 留档,只是不进这一列人读的动作名。
        // 🔴 **先选定,再施加**,不再「取第一份就 break」(A1-a,2026-08-03 Codex 终审 High③)。
        // 旧写法在真实数据形态下选错了:一次复核保存必然同时带确定签名(is_review_button=false)
        // 与复核签名(is_review_button=true),而候选顺序由表单配置决定、实测确定那份恒排在前,
        // 于是 REVIEW 永远轮不到——实测 audit_events id 101 出的是 action_code=CREATE +
        // bizButton=「确定」,全库仅有的 3 条 REVIEW 全部来自 e2e 脚本人为构造的靶子记录
        // (那条记录 biz_sign 列刻意为空),**真实用户走不到 REVIEW**。
        // 「一次操作只认一份动作语义」的约束不变,变的是选哪一份:复核证据优先,没有才取第一份。
        // 🔴 复核证据只有在它**真的能改写动作语义**时才优先(2026-08-03 端到端第二次实测补)。
        // CREATE/DELETE 是生命周期硬事实、覆盖不了,那时仍取第一份——也就是触发这次保存/删除
        // 的那个按钮。否则 extra.bizButton 会记成「复核」,而复核并没有产生这次数据变更
        // (它自己那条签名事件已经独立记着了)。实测 audit_events id 146:新建时同时点了复核,
        // CREATE 事件的 bizButton 被记成「复核」,把「用户点确定保存」这个事实盖掉了。
        // **短路放在选证据这一步、不放在下游**:放下游就会出现「选中了却用不上」的中间态,
        // action_code 与 extra.bizButton 各说各话。
        // 🔴🔴 **REVIEW 只认请求携带的签名**(2026-08-03 Codex 终审缺陷 1/3/4,用户拍板走方案 B)。
        // 反查 audit_events 只是「这次刚签的吗」的**代理指标**,而它两个方向都会错:
        //   · 查不到 ≠ 刚签的——层 0 上线前的老数据、当年 MQ 丢过事件的记录都查不到,
        //     于是一次普通保存会被**永久**写成 REVIEW(append-only,改不回来);
        //   · 查得到 ≠ 历史——MQ 乱序时,同一条记录的两次操作谁先被消费不定,
        //     后发生的那次可能先落库,把先发生的那次挤成「历史」。
        // 信息本身不在那张表里,加谓词补不了。所以**动作语义这一侧退回删除路径的同口径**:
        // 只有 REQUEST_SIGN_FIELD(请求顶层显式带下来的签名)才够格改写 action_code。
        // 代价是前端补传之前 REVIEW 出不来——但那是诚实的「判不了」,不是错的「判成了」。
        // 反查**保留**,只用在低风险的那一半:抑制重复的签名事件(错了顶多多发/少发一条,
        // 且下次操作自愈,不会往业务事件上焊死一个错误的 action_code)。
        // 根治方向是服务端在消费签名时显式记录归属(lims_sign.data_id 现在是空的),
        // 排进批 D 与 High①(签名授权)一起做。
        boolean reviewCanApply = !delete && !create;
        String bizButtonLabel = null;
        AuditSignMetaData md = null;   // 选定的那一份动作语义;null = 本次操作没有签名证据
        // md 是从数据列证据里选中的(= 它「是不是这次签的」由反查这个代理指标判出来,可能错)。
        // 只影响 extra 留痕的可信度标注,不参与任何判定(2026-08-05 Codex 复审 Medium②)。
        boolean mdFromDataField = false;
        boolean reviewSeenButUntrusted = false;
        // md 选中的那份复核证据**是不是请求携带的**。只看 md.isReviewButton() 不够——
        // 兜底分支(md == null 时取第一份)照样会把数据列里的复核证据放进 md,
        // 于是 action_code 又被它改写了(2026-08-03 端到端第三次实测,audit_events id 204)。
        // 「够不够格改写」是**来源**属性,不是内容属性,必须单独记,不能从 md 反推。
        boolean reviewFromRequest = false;
        for (SignCandidate candidate : signCandidates) {
            AuditSignEvidence evidence = evidences.get(candidate.signId);
            // 判为历史的证据**只为 reviewUndecidable 留痕才解析**:parse 会整份 JSON 解出来
            // 再把 biz_data 重新序列化一遍(实测可达数百 KB),批量删 N 条 × M 个历史签名列
            // 全解析会压垮 MQ 消费线程;而 delete/create 路径上 reviewUndecidable 恒不产出
            // (reviewCanApply=false),那里解析纯属白费。
            if (evidence == null || (!candidate.thisOperation && !reviewCanApply)) {
                continue;
            }
            AuditSignMetaData parsed = AuditSignMetaData.parse(evidence.metaData());
            // 🔴 留痕条件**与历史判定解耦**(2026-08-05 Codex 复审 Medium②):数据列里的复核
            // 证据无论被反查判成本次还是历史,动作语义都不认它、用户看到的都是「没有 REVIEW」,
            // 那就都得留下「为什么没有」。此前这行挂在 thisOperation 守卫之后——被判成历史的
            // 那条复核证据连 reviewUndecidable 都不留,把「判不了」又伪装成了「没发生」(L075)。
            if (!candidate.fromRequest && parsed.isReviewButton()) {
                reviewSeenButUntrusted = true;
            }
            if (!candidate.thisOperation) {
                continue;   // 历史签名的 meta_data 描述的是当时那次操作,不是这次
            }
            if (md == null) {
                md = parsed;   // 没有复核证据时的兜底 = 第一份(既有行为)
                mdFromDataField = !candidate.fromRequest;
            }
            if (!parsed.isReviewButton()) {
                continue;
            }
            if (candidate.fromRequest) {
                md = parsed;
                mdFromDataField = false;
                reviewFromRequest = true;
                break;   // 请求携带的复核证据优先,找到即定
            }
            // 数据列里的复核证据:判不了它是不是这次签的,**不拿它改写 action_code**
            // (留痕已在上面与历史判定解耦地做过了)。
        }
        if (reviewCanApply && reviewSeenButUntrusted && !AuditConsts.ACT_REVIEW.equals(actionCode)) {
            extra.put("reviewUndecidable", "dataFieldOnly");
        }
        // REVIEW 覆盖同时短路 delete **与 create**(见上面 reviewCanApply):
        // 「新建一条记录的同时点了复核」是真实工作流(实测 audit_events id 101、119),
        // 而 CREATE 与 DELETE 一样是**生命周期硬事实**——按 action_code='CREATE' 筛时这条
        // 记录的创建事实会整个消失,与下面注释①骂 DELETE 那条是同一个红线。
        // 这个坑在 A1-a 之前就潜伏着(候选顺序里复核排前就会中招,只是实测确定恒排前所以
        // 没现形),A1-a 把「复核证据优先」定死之后变成必现,由端到端第 ① 场景抓出。
        // 复核这件事没有丢:复核签名照样产出独立的签名事件。
        boolean reviewApplied = reviewCanApply && reviewFromRequest;
        if (md != null) {
            if (reviewApplied) {
                actionCode = AuditConsts.ACT_REVIEW;
            }
            if (md.bizButton() != null && !md.bizButton().isEmpty()) {
                extra.put("bizButton", md.bizButton());   // 机器可读留档,恒留
                if (mdFromDataField) {
                    // 这份按钮名来自**数据列**证据,而「它是不是这次操作签的」是反查这个
                    // 代理指标判出来的——两个方向都可能错(老数据查不到 / MQ 乱序)。
                    // 请求携带的证据没有这个不确定性,两者必须在查询侧分得开
                    // (2026-08-05 Codex 复审 Medium②)。
                    extra.put("bizButtonSource", "dataField");
                }
                // 🔴 只有复核按钮的文案才够格当 action_label(2026-08-02 端到端实测修复)。
                // 收窄前这里无条件覆盖,于是新增/编辑两次实测的 action_label 都是「确定」——
                // 前端确定按钮上那个 UI 文案,把「新建」「编辑」这两个系统判定出来的动作词
                // 顶掉了:action_code 还是对的(CREATE/UPDATE),只有给人读的这一列退化成了
                // 界面文案。复核是唯一的例外,因为 is_review_button=true 时上面同时把
                // action_code 改成了 REVIEW——动作码已经变了,动作名跟着走才自洽;而且这条
                // 分支的 biz_button 值域受控(is_biz_form=true 的平台按钮),不是业务开发者
                // 在列表设计器里随手起的名字。
                // 判据用 reviewApplied 而不是 md.isReviewButton():上面那句「动作码已经变了、
                // 动作名跟着走才自洽」只有在覆盖**真的发生了**时才成立。create/delete 被短路时
                // action_code 仍是 CREATE/DELETE,动作名再显示「复核」就又成了那个
                // 「既不是模块也不是动作的混合物」。两者绑同一个布尔量,不靠巧合对齐。
                if (reviewApplied) {
                    bizButtonLabel = md.bizButton();
                }
            }
            if (md.rawBizData() != null) {
                // 批量场景下每条事件共享同一份整批快照,N 条事件各存一份就是 N² 膨胀
                // (500 行 × 250KB × 500 条 ≈ 125MB,AUDIT_SNAPSHOT_LIMIT=500 时的实测量级),
                // 而 extra 列不在 clip 白名单里、无护栏。批量时它对单条记录本来就没有指向性,
                // 不写;单条时也设上限,防超大表单。
                if (batchSize != null && batchSize > 1) {
                    extra.put("frontendBizDataOmitted", "batch");
                } else {
                    String raw = md.rawBizData();
                    if (raw.length() > FRONTEND_BIZ_DATA_MAX) {
                        extra.put("frontendBizData", raw.substring(0, FRONTEND_BIZ_DATA_MAX));
                        extra.put("frontendBizDataTruncated", raw.length());
                    } else {
                        extra.put("frontendBizData", raw);
                    }
                }
            }
        }
 
        // ── 记录标题(spec §5.2)───────────────────────────────────────────────────
        Set<String> titleExcluded = new LinkedHashSet<>(masked);
        titleExcluded.addAll(signFields);
        AuditEventEntity titleContext = new AuditEventEntity();
        titleContext.setTargetTable(table);
        titleContext.setTenantId(ctx.tenantId);
        titleContext.setEventTime(ctx.eventTime);
        titleContext.setActionCode(actionCode);
        Map<String, String> frontendDisplayValues = frontendDisplayValues(
                form.get("auditDisplayFields"), titleSpec, titleExcluded);
        AuditRecordTitle title = recordTitleBuilder.build(
                titleSpec, eventRow, titleContext, labels, titleExcluded, frontendDisplayValues);
        if (!frontendDisplayValues.isEmpty()) {
            extra.put("titleDisplaySource", "frontendSubmit");
        }
        String baseRecordTitle = recordTitleBuilder.resolveMainTitle(
                titleSpec, title, eventRow, dataId);
        if (titleSpec.isEmpty()) {
            // 一个字段都没勾——当前库里 148 份表单的真实状态。delete 分支下这句指的是
            // 「列表一个字段都没勾」(D6 分流,见上),非 delete 分支指的是表单勾选。
            // 标题回落到当前业务记录 f_id;留痕以便区别于「配了但渲染不出来」。
            extra.put("titleScope", "unconfigured");
            if (baseRecordTitle != null) {
                extra.put("titleFallback", "f_id");
            }
        }
        if (!title.subTitles().isEmpty()) {
            extra.put("recordSubTitles", title.subTitles());
            if (title.subTable() != null) {
                extra.put("recordSubTitleTable", title.subTable());
            }
        }
        if (title.subDataUnavailable()) {
            extra.put("subTitleSource", "unavailable");
        }
        // 子表标题完整性(2026-08-05 Codex 复审 Low)。两道上限一直是**悄悄**截的,读的人
        // 看到 200 条会以为那就是全部;资源上界下推之后又多一种截法——额度耗尽时后面整张
        // 子表都不碰,连 subDataUnavailable 都测不出来。缺席 = 完整,不缺席就说清是哪种截断。
        if (title.subScope() != null) {
            extra.put("subTitleScope", title.subScope());
        }
 
        // D3:批量删除标题缀「共 N 条」。批量操作物理合并后,主行只显示其中一条的标题——
        // 不缀条数,用户看不出这一行代表的是 1 条还是 50 条。N 来自 Task 1 已加的
        // VisualLogForm.batchSize(每条事件都带同一个值),审计侧直接读——batchSize 变量已在
        // 前面的动作语义覆盖块之前读取(frontendBizData 膨胀防护也要用),这里直接复用。
        String recordTitle = baseRecordTitle;
        if (batchSize != null && batchSize > 1) {
            extra.put("batchSize", batchSize);
            if (recordTitle != null && !recordTitle.isEmpty()) {
                recordTitle = recordTitle + "(共 " + batchSize + " 条)";
            }
        }
 
        String bizCode = bizCode(snapshotRow, meta);
        String bizModule = meta.getBizModule() != null ? meta.getBizModule() : ctx.appCode;
        if (bizModule.equals(ctx.appCode)) {
            String visualDevFullName = registry.resolveFullName(modelId);
            if (visualDevFullName != null) {
                bizModule = visualDevFullName;
            }
        }
        AuditEventDTO dto = AuditEventDTO.builder()
                .clientEventId(EVENT_ID_PREFIX + event.getEventId())
                // 没有 batchOpId(新增/普通修改)时 operationId 与 clientEventId 仍同值,
                // 分组结果与改动前逐字节一致;批量删除时两者不同值,靠 operationId 把
                // 一批 N 条事件在写入侧合并为一行,clientEventId 继续按来源事件唯一。
                .operationId(operationId)
                .eventTime(ctx.eventTime)
                .tenantId(ctx.tenantId)
                .operatorId(ctx.userId)
                .operatorName(ctx.userName)
                .appName(SOURCE_APP)
                .bizModule(bizModule)
                .ip(ctx.ip)
                .browser(ctx.browser)
                .os(ctx.os)
                .requestUri(ctx.uri)
                .eventType(AuditConsts.TYPE_DATA_CHANGE)
                .actionCode(actionCode)
                // actionLabel(boolean) 本身仍是二分支(新增/修改)。delete 优先级最高、恒显示
                // 「删除」——不接受 meta_data 的 bizButtonLabel 覆盖(评审修复轮次 1,理由见
                // 前面动作语义覆盖块的 🔴 注释②:业务开发者随手起的列表按钮文案不该冒充删除
                // 这个硬事实)。非删除场景才轮到 bizButtonLabel 优先于 actionLabel(create)。
                // 不拼模块名,见 actionLabel 的 javadoc。
                .actionLabel(delete ? "删除"
                        : (bizButtonLabel != null ? bizButtonLabel : actionLabel(create)))
                .sourceLayer(0)
                .targetTable(table)
                .targetId(dataId)
                .bizType(meta.getBizType())
                .bizCode(bizCode)
                .fieldDiffs(fieldDiffs)
                .dataSnapshot(snapshotRow == null ? null
                        : AuditFieldDiff.snapshot(snapshotRow, masked, labels))
                .extra(JsonUtil.getObjectToString(extra))
                .recordTitle(recordTitle)
                .entryType(entryType)
                .entryId(entryId)
                .entryName(entryName)
                .build();
        auditEventService.saveIdempotent(dto);
 
        // ── 签名事件(spec §6)─────────────────────────────────────────────────────
        // **顺序不可调换**:业务事件先落库,签名事件才写。表单没保存成功 → 没有 MQ 事件
        // → 这里根本不会执行 → 签名日志天然不产生。药厂要的「业务失败则签名也不记」
        // 由链路结构保证,不靠事后补偿。
        //
        // 每个签名一条,clientEventId 带 -SIGN-<signId> 后缀(多签名场景互不覆盖),
        // 靠 UNIQUE(client_event_id) 挡广播重复消费。
        // 标题/入口/单号继承业务事件——即使单独按 eventType=E_SIGNATURE 查,
        // 也看得到"签的是哪张单子"。
        // 请求顶层与保存后的数据列经常携带同一个 signId。候选必须都保留到动作语义判定,
        // 但签名事实只能输出一次;请求候选排在最前,因此重复时稳定保留请求来源的展示标签。
        Set<String> emittedSignIds = new LinkedHashSet<>();
        for (SignCandidate candidate : signCandidates) {
            AuditSignEvidence evidence = evidences.get(candidate.signId);
            if (evidence == null) {
                continue;
            }
            if (!candidate.thisOperation) {
                continue;   // 历史签名不在这里重发,已记进 extra.historicalSignRefs
            }
            if (!emittedSignIds.add(candidate.signId)) {
                continue;
            }
            Map<String, Object> signExtra = new LinkedHashMap<>();
            signExtra.put("signField", candidate.signField);
            signExtra.put("sourceModelId", modelId);
            AuditSignMetaData signMetaData = AuditSignMetaData.parse(evidence.metaData());
            boolean reviewSignature = signMetaData.isReviewButton();
            signExtra.put("signatureRole", reviewSignature ? "REVIEW" : "PRIMARY");
            if (evidence.accountName() != null) {
                signExtra.put("accountName", evidence.accountName());
            }
            if (evidence.opType() != null) {
                signExtra.put("opType", evidence.opType());
            }
            AuditEventDTO signDto = AuditEventDTO.builder()
                    .clientEventId(operationId + "-SIGN-" + evidence.signId())
                    .operationId(operationId)
                    // 签名时刻取签名凭证自身的创建时刻,而不是本次表单保存时刻——
                    // 两者之间隔着用户填表的时间,混用会让时间线错位。
                    .eventTime(evidence.signedAt() != null ? evidence.signedAt() : ctx.eventTime)
                    .tenantId(ctx.tenantId)
                    .operatorId(evidence.operatorId() != null ? evidence.operatorId() : ctx.userId)
                    .operatorName(ctx.userName)
                    .appName(SOURCE_APP)
                    .bizModule(bizModule)
                    .ip(ctx.ip)
                    .browser(ctx.browser)
                    .os(ctx.os)
                    .requestUri(ctx.uri)
                    .eventType(AuditConsts.TYPE_E_SIGNATURE)
                    // 与 A1-b 历史反查同一组常量,别退回字面量(见 SIGN_ACTION_CODE 的 javadoc)
                    .actionCode(SIGN_ACTION_CODE)
                    .actionLabel(reviewSignature ? "复审签名" : "电子签名")
                    .sourceLayer(SIGN_SOURCE_LAYER)
                    // 与 A1-b 历史反查同一个常量,别退回字面量(见 SIGN_TABLE 的 javadoc)
                    .targetTable(AuditSignEvidenceReader.SIGN_TABLE)
                    .targetId(evidence.signId())
                    .bizType(meta.getBizType())
                    .bizCode(bizCode)
                    .reason(evidence.note())
                    .extra(JsonUtil.getObjectToString(signExtra))
                    .recordTitle(baseRecordTitle)
                    .entryType(entryType)
                    .entryId(entryId)
                    .entryName(entryName)
                    .build();
            auditEventService.saveIdempotent(signDto);
        }
    }
 
    /** READ 不保存查询参数、返回数据或字段快照,只记录访问事实。 */
    private void recordRead(ProjectEventInstance event, Map<String, Object> form,
                            AuditFormRegistry.FormMeta meta, String table, String dataId,
                            AuditCtx ctx, Map<String, Object> extra,
                            String entryType, String entryId, String entryName) {
        String readScope = str(form.get("readScope"));
        if (readScope == null) {
            readScope = dataId == null ? "LIST" : "DETAIL";
        }
        extra.put("readScope", readScope);
        String eventId = EVENT_ID_PREFIX + event.getEventId();
        AuditEventDTO dto = AuditEventDTO.builder()
                .clientEventId(eventId)
                .operationId(eventId)
                .eventTime(ctx.eventTime)
                .tenantId(ctx.tenantId)
                .operatorId(ctx.userId)
                .operatorName(ctx.userName)
                .appName(SOURCE_APP)
                .bizModule(meta.getBizModule() != null ? meta.getBizModule() : ctx.appCode)
                .ip(ctx.ip)
                .browser(ctx.browser)
                .os(ctx.os)
                .requestUri(ctx.uri)
                .eventType(AuditConsts.TYPE_BIZ_ACTION)
                .actionCode(AuditConsts.ACT_READ)
                .actionLabel("DETAIL".equalsIgnoreCase(readScope) ? "查看详情" : "查询列表")
                .sourceLayer(0)
                .targetTable(table)
                .targetId(dataId)
                .bizType(meta.getBizType())
                .extra(JsonUtil.getObjectToString(extra))
                .entryType(entryType)
                .entryId(entryId)
                .entryName(entryName)
                .build();
        auditEventService.saveIdempotent(dto);
    }
 
    /**
     * 这份签名候选是不是「本次操作」签的。三条互斥的分支,顺序即优先级:
     *
     * <ol>
     *   <li><b>请求携带的恒为真</b>({@link SignCandidate#fromRequest}):它按定义就是本次操作的
     *       凭证,不需要、也不能拿历史反查去否定它;</li>
     *   <li><b>删除场景的数据列恒为假</b>:被删数据里的签名列存的是历史,见下方长注释(P1b);</li>
     *   <li><b>非删除场景的数据列查 {@code recordedSignIds}</b>:这个 signId 已经被记过一条
     *       签名事件(且不是本次 operation 记的)⇒ 历史。见 {@link #recordedSignIds}。</li>
     * </ol>
     *
     * <p><b>非删除场景为什么不能像原先那样恒为真</b>(2026-08-03 Codex 终审 High③):实测
     * 一次复核保存会生成两条签名(确定 + 复核),分别落进 {@code biz_sign} / {@code fuhe_sign};
     * {@code biz_sign} 每次保存都刷新成新的确定签名,而 {@code fuhe_sign} 只在复核那次写——
     * 之后每次普通保存都会把它重发一条签名事件,{@code event_time} 停在上次复核那一刻却挂在
     * 这次保存的 {@code operation_id} 下。就是 L078 骂的时间倒流,换到了更新路径上。
     *
     * <p><b>删除场景只认请求携带的那一份</b>({@code fromRequest},Task 1 平台补丁
     * 从请求体的 {@code biz_sign} 传下来)。被删数据里的 {@code biz_sign}/{@code fuhe_sign}
     * 存的是这条记录<b>创建/编辑时</b>的历史签名,它们各自早在当时那次操作里记过一条签名事件了;
     * 在删除事件上重发一遍会同时踩三个坑(2026-08-02 端到端实测,audit_events id 39/40/43-46):
     * <ol>
     *   <li><b>时间倒流</b>:签名事件的 {@code event_time} 取签名凭证自身的创建时刻,于是
     *       15:08 的一次删除产出了四条 {@code event_time} 停在 <b>7-31</b> 的「签名」事件,
     *       挂在这次删除的 {@code operation_id} 下——写入侧合并成一行后,看起来就是
     *       「这次删除时签了四个名」,而事实是一个都没签在这次删除上;</li>
     *   <li><b>批量放大</b>:批量删 N 条 × 每条 M 个历史签名列 = N×M 条签名事件,实测删 2 条
     *       产出 4 条,本该只有用户真正签的那 1 条;</li>
     *   <li><b>动作语义取错</b>:动作语义只认第一份证据,历史签名的 {@code meta_data} 会把
     *       当时那次操作的 {@code biz_button}(实测「确定」)和 {@code biz_data} 当成这次删除的
     *       上下文写进 {@code extra}。</li>
     * </ol>
     *
     * <p><b>为什么不干脆把它们从 {@code signCandidates} 里剔掉</b>:这个列表还兼着
     * 「哪些字段不许进标题」(见 {@code titleExcluded})。命名规则命中的签名列有
     * {@code masked} 兜底,但 D4 的配置来源({@code confirmBtnConfig.biz_sign_field})允许
     * 任意字段名,剔掉就等于让一个签名 id 明文进标题——审计表 append-only 存十年,洗不掉。
     * 所以只在「产出事件」这一侧收窄。
     *
     * <p><b>入参是 {@code fromRequest} 布尔量而不是字段名</b>(2026-08-05 Codex 复审 High①):
     * 拿字段名与 {@link #REQUEST_SIGN_FIELD} 比对,等于让一个可碰撞的字符串来决定
     * 「这份证据够不够格」——业务表真有同名列时判定就被劫持了。来源跟着候选走。
     */
    private static boolean signedByThisOperation(boolean delete, boolean fromRequest,
                                                 String signId, Set<String> recordedSignIds) {
        if (fromRequest) {
            // 🔴 **审计层不做授权判定**(2026-08-05 定案)。这里一度收过一个 authorized 参数,
            // 由「签名人 == 操作人」推出——那个判据不成立:verify_user 的多人会签本就要求
            // 两者不同,而「合法会签」与「盗用 signId」在数据上不可区分(见授权循环处长注释)。
            // 请求携带的凭证按定义就是本次操作提交的凭证,它是不是**该**被提交,
            // 由平台层同步校验回答,不是这里。不一致的事实已在 extra.signerDiffersFromOperator
            // 里如实标注——照出来,不拦下来。
            // (凭证压根不存在/跨租户的那一类是**可判定**的,已在候选剔除那一步拦掉并留痕。)
            return true;
        }
        if (delete) {
            return false;
        }
        return recordedSignIds == null || !recordedSignIds.contains(signId);
    }
 
    /**
     * 这批 signId 里哪些**已经被记过签名事件**(= 历史)。
     *
     * <p><b>判据为什么取自 audit_events</b>(用户 2026-08-03 拍板 D7):候选是不是「本次刚签的」,
     * 唯一不可篡改的依据就是「审计表里记过没有」。{@code lims_sign.meta_data}(含
     * {@code biz_data})整块由前端 POST、后端原样落库、零校验,拿它判定等于把「凭空产出一条
     * 签名事件」的口子交给前端报文;而 {@code data_snapshot} 里签名列是 {@code ***},
     * 拿 baseline 比对「签名列变没变」也永远比不出来。
     *
     * <p><b>为什么排除本次 operation_id</b>:MQ 重投递会让同一条事件被消费两次,批量场景下
     * N 条事件还共享同一个 {@code batchOpId}。不排除的话,第一条事件写下的签名事件会让后面
     * 那些「同一次操作」的事件把它当成历史——判定必须对「同一次操作」保持稳定。
     *
     * <p><b>自愈</b>:某条签名当年因 MQ/落库失败没记上,下一次操作会把它当「本次」补一条。
     * 补上的那条 {@code event_time} 仍是签名凭证自身的时刻,但 {@code operation_id} 属于补记
     * 的那次操作——这是可见的代价,好过永远查不到这条签名。
     *
     * @return 已记过的 signId 集合;<b>查询失败返回 {@code null}</b>——调用方据此走 D8 的
     *         fail-closed(全判历史 + {@code extra.signHistoryDegraded}),不要当成空集。
     */
    private Set<String> recordedSignIds(Collection<String> signIds, String operationId,
                                        String tenantId) {
        try {
            List<String> rows = auditEventMapper.selectRecordedSignIds(
                    AuditSignEvidenceReader.SIGN_TABLE, AuditConsts.TYPE_E_SIGNATURE,
                    SIGN_ACTION_CODE, SIGN_SOURCE_LAYER, signIds, operationId, tenantId);
            Set<String> recorded = new LinkedHashSet<>();
            if (rows != null) {
                for (String id : rows) {
                    if (id != null && !id.trim().isEmpty()) {
                        recorded.add(id.trim());
                    }
                }
            }
            return recorded;
        } catch (Throwable t) {
            log.warn("[AUDIT-L0] 历史签名反查失败,本次按 fail-closed 全判历史 count={} err={}",
                    signIds.size(), t.toString());
            return null;
        }
    }
 
    /**
     * 一条历史签名的可追引用(A2,Codex 终审 Medium⑤)。
     *
     * <p><b>刻意不带本次的 operation/eventTime</b>:这是历史事实的引用,不是本次操作的证据
     * (L078 的规矩)。{@code signedAtMillis} 取签名凭证自身的创建时刻(epoch 毫秒),
     * 缺席表示凭证行读不到创建时刻,不是「没签过」。
     */
    private static Map<String, Object> historicalSignRef(String signField, String signId,
                                                         AuditSignEvidence evidence) {
        Map<String, Object> ref = new LinkedHashMap<>();
        ref.put("signField", signField);
        ref.put("signId", signId);
        if (evidence != null && evidence.signedAt() != null) {
            ref.put("signedAtMillis", evidence.signedAt().getTime());
        }
        return ref;
    }
 
    /**
     * CREATE 时应当以「已知为空」显式写进 baseline 的字段(小写、稳定顺序)。
     *
     * <p><b>要解决的事实</b>(2026-07-30 联调报障,本机实测复现):在线表单保存时,平台会把值为空
     * 的字段整个从 {@code newData} 里剥掉——{@code lims_qingyandan} 的 CREATE 事件快照只有 21 键,
     * 而已发布表单模型有 23 个字段,少的正是建单时留空的那几个。结果是这些字段**第一次被填写**
     * 时,UPDATE 分支的两侧交集({@code onlyFields} 互取对方 keySet)把它们整个排除,
     * {@code field_diffs} 漏记一次真实变更;它们进过一次快照之后反而正常了。
     *
     * <p><b>补的是"已知为空",不是"猜一个旧值"</b>。判据来自
     * {@link AuditFormRegistry#resolveBaselineFields}:该字段属于**这张已发布表单**、且确是主表
     * 一列。建单提交的就是整份表单,没被携带即等于留空——这是有据可依的推断,而不是对
     * 数据库写入前快照里的未知字段瞎填。该补齐只用于 CREATE 的操作后快照,UPDATE 差异
     * 始终直接使用数据库写入前后的实际查询结果。
     *
     * <h3>四类字段不补,每一类都有代价对应</h3>
     * <ul>
     *   <li><b>本次已携带</b>:{@code eventRow} 里有的值优先,补了只会造出大小写重复键。</li>
     *   <li><b>平台技术列</b>:{@code forDiff} 本就要剔,补进来纯噪音。</li>
     *   <li><b>脱敏列 / 签名控件</b>:层 0 判定不了它们的变更(快照里存的是占位符),
     *       补一个"已知为空"等于宣称"初始密码是第一次设置"——这正是 B5 评审 M-3 不给的那半格。</li>
     *   <li><b>子表字段</b>:baseline 只存主表快照,子表明细本层给不出(见
     *       {@link AuditFieldDiff.FieldMeta} 类注释),补空会把"判不了"伪装成"本来就没有"。</li>
     * </ul>
     */
    static List<String> knownEmptyFields(Map<String, Object> eventRow,
                                         Set<String> formFields,
                                         Set<String> masked,
                                         Map<String, AuditFieldDiff.FieldMeta> labels) {
        if (formFields == null || formFields.isEmpty()) {
            return Collections.emptyList();
        }
        Set<String> carried = new java.util.HashSet<>();
        if (eventRow != null) {
            for (String key : eventRow.keySet()) {
                if (key != null) {
                    carried.add(key.toLowerCase(Locale.ROOT));
                }
            }
        }
        List<String> known = new ArrayList<>();
        for (String field : formFields) {
            if (field == null) {
                continue;
            }
            String lower = field.toLowerCase(Locale.ROOT);
            if (carried.contains(lower) || isTechColumn(lower) || isMasked(lower, masked)
                    || isCredentialReference(lower)) {
                continue;
            }
            AuditFieldDiff.FieldMeta fm = labels == null ? null : labels.get(lower);
            if (AuditFieldDiff.isCredentialMetadata(fm)) {
                continue;
            }
            if (fm != null && AuditFieldDiff.JNPF_KEY_TABLE.equals(fm.jnpfKey())) {
                continue;
            }
            known.add(lower);
        }
        return known;
    }
 
    /**
     * 把「已知为空」显式写进 CREATE 快照;事件明确携带的值恒优先。
     *
     * <p>用空串而不是 {@code null}:{@code data_snapshot} 落库前会过
     * {@link AuditFieldDiff#snapshot}({@code stringify(null)} 同样得空串),两者存进去一模一样,
     * 但空串让人直接读 JSON 时看得出"这个字段当时就是空的",而不是像个漏写的键。
     * 比较侧不受影响:{@link AuditFieldDiff#valuesEqual} 把 null 与空串一律视作空。
     */
    static Map<String, Object> withKnownEmpty(Map<String, Object> eventRow, List<String> knownEmpty) {
        if (knownEmpty == null || knownEmpty.isEmpty()) {
            return eventRow;
        }
        Map<String, Object> row = new LinkedHashMap<>();
        for (String field : knownEmpty) {
            row.put(field, "");
        }
        if (eventRow != null) {
            row.putAll(eventRow);
        }
        return row;
    }
 
    /**
     * 从事件的 {@code listLog} 里提取「字段名 → 展示元数据」。
     *
     * <p>这是层 0 相对层 1 白捡的一块元数据:listLog 由 visualdev 按**在线表单模型**生成,
     * 随表单发布自动更新,不需要任何手写映射表({@code AuditFieldDiff} 类注释里说的
     * 「正确的来源是元数据」)。
     *
     * <p><b>只取元数据、不取它的 oldData/newData</b>——那两个是 visualdev 用不可信的
     * oldData 算出来的**值**(L003),值一律以 PG baseline 为准。
     * 取的三个({@code fieldName}/{@code jnpfKey}/{@code nameModified})都是**字段的属性**
     * 而不是这次改了什么,故不受 L003 约束。
     *
     * <p>{@code jnpfKey}/{@code nameModified} 是 D1 补的(见 {@link AuditFieldDiff.FieldMeta}):
     * 它们各自对应前端一条渲染分支,缺了 {@code nameModified} 会把字典字段的存储原值当明文渲染。
     */
    private Map<String, AuditFieldDiff.FieldMeta> fieldMeta(Object listLog) {
        if (!(listLog instanceof List)) {
            return null;
        }
        Map<String, AuditFieldDiff.FieldMeta> meta = new LinkedHashMap<>();
        for (Object item : (List<?>) listLog) {
            Map<String, Object> row = asMap(item);
            if (row == null) {
                continue;
            }
            String field = str(row.get("field"));
            if (field == null) {
                continue;
            }
            // nameModified 在平台 VisualLogModel 上是 boolean 基本类型,跨 JVM 反序列化后
            // 按 Map 取(L004)可能是 Boolean 也可能是字符串 "true",两种都认;认不出就传 null
            // (= 该键不出现 = 前端走明文分支),**不猜默认值**——猜错的方向是「把该藏的值露出来」。
            Object nm = row.get("nameModified");
            Boolean nameModified = null;
            if (nm instanceof Boolean) {
                nameModified = (Boolean) nm;
            } else if (nm != null) {
                String s = String.valueOf(nm).trim();
                if ("true".equalsIgnoreCase(s)) {
                    nameModified = Boolean.TRUE;
                } else if ("false".equalsIgnoreCase(s)) {
                    nameModified = Boolean.FALSE;
                }
            }
            meta.put(field, new AuditFieldDiff.FieldMeta(str(row.get("fieldName")),
                    str(row.get("jnpfKey")), nameModified));
        }
        return meta.isEmpty() ? null : meta;
    }
 
    /** 从平台原生 listLog 中提取已由数据库前后快照计算完成的子表行级变化。 */
    @SuppressWarnings("unchecked")
    static List<Map<String, Object>> childDiffs(Object listLog) {
        if (!(listLog instanceof List)) {
            return Collections.emptyList();
        }
        List<Map<String, Object>> result = new ArrayList<>();
        for (Object raw : (List<?>) listLog) {
            if (!(raw instanceof Map)) {
                continue;
            }
            Map<String, Object> row = (Map<String, Object>) raw;
            if (str(row.get("field")) == null
                    || !AuditFieldDiff.JNPF_KEY_TABLE.equals(str(row.get("jnpfKey")))
                    || !(row.get("chidData") instanceof List)
                    || ((List<?>) row.get("chidData")).isEmpty()
                    || !(row.get("chidField") instanceof List)
                    || ((List<?>) row.get("chidField")).isEmpty()) {
                continue;
            }
            Map<String, Object> child = new LinkedHashMap<>();
            child.put("field", row.get("field"));
            child.put("fieldName", row.get("fieldName"));
            child.put("jnpfKey", AuditFieldDiff.JNPF_KEY_TABLE);
            child.put("componentType", AuditFieldDiff.JNPF_KEY_TABLE);
            child.put("targetTable", row.get("targetTable"));
            child.put("type", row.get("type") == null ? 1 : row.get("type"));
            child.put("nameModified", false);
            child.put("chidData", row.get("chidData"));
            child.put("chidField", row.get("chidField"));
            result.add(child);
        }
        return result;
    }
 
    @SuppressWarnings("unchecked")
    static String appendChildDiffs(String fieldDiffs, List<Map<String, Object>> childDiffs) {
        if (childDiffs == null || childDiffs.isEmpty()) {
            return fieldDiffs;
        }
        List<Object> merged = new ArrayList<>();
        if (fieldDiffs != null && !fieldDiffs.trim().isEmpty()) {
            try {
                Object parsed = JSON.parse(fieldDiffs);
                if (parsed instanceof List) {
                    merged.addAll((List<Object>) parsed);
                }
            } catch (Throwable ignored) {
                // 普通字段 JSON 损坏时仍保留可信的子表变化。
            }
        }
        merged.addAll(childDiffs);
        return JSON.toJSONString(merged);
    }
 
    static Map<String, Object> withoutFields(Map<String, Object> row, Set<String> fields) {
        if (row == null || row.isEmpty() || fields == null || fields.isEmpty()) {
            return row;
        }
        Set<String> normalized = new LinkedHashSet<>();
        for (String field : fields) {
            if (field != null) {
                normalized.add(field.toLowerCase(Locale.ROOT));
            }
        }
        Map<String, Object> result = new LinkedHashMap<>();
        for (Map.Entry<String, Object> entry : row.entrySet()) {
            if (entry.getKey() == null || !normalized.contains(entry.getKey().toLowerCase(Locale.ROOT))) {
                result.put(entry.getKey(), entry.getValue());
            }
        }
        return result;
    }
 
    /**
     * 本次变更里命中「子表」控件的字段名。
     *
     * <p>标准在线表单事件通过 listLog 携带平台按数据库前后数据算出的 {@code chidData};
     * 只有补丁上线前的旧事件或非标准发布方缺少 listLog 时,层 0 才无法还原子表行级明细。
     * 此时把「判不了」如实写进 extra,避免把空明细误解成「子表没有修改」。
     */
    private List<String> subTableFields(Map<String, AuditFieldDiff.FieldMeta> meta) {
        if (meta == null) {
            return java.util.Collections.emptyList();
        }
        List<String> hit = new ArrayList<>();
        for (Map.Entry<String, AuditFieldDiff.FieldMeta> e : meta.entrySet()) {
            if (AuditFieldDiff.JNPF_KEY_TABLE.equals(e.getValue().jnpfKey())) {
                hit.add(e.getKey());
            }
        }
        return hit;
    }
 
    /**
     * 业务单号。<b>取不到不丢事件</b>(与旧 listener 的关键差异,见类注释)。
     * 未配置取值字段时直接留空——大量表单本就没有业务单号这一说。
     */
    private String bizCode(Map<String, Object> newData, AuditFormRegistry.FormMeta meta) {
        String field = meta.getBizCodeField();
        if (field == null || field.isEmpty() || newData == null) {
            return null;
        }
        return str(newData.get(field));
    }
 
    /**
     * 层 0 的动作名只给<b>纯动作词</b>。
     *
     * <p>模块语义已经由 {@code entry_name}(用户点进来的菜单)承担,再把它拼进动作名
     * 就成了「LIMS-请验单 新建」这种既不是模块也不是动作的混合物。
     * 层 2 事件(列表按钮)仍用各自的 {@code actionLabel}——那里的动作词是业务自定义的。
     */
    private String actionLabel(boolean create) {
        return create ? "新增" : "修改";
    }
 
    /**
     * diff 的输入预处理:剔除平台技术列。脱敏列保留到比较阶段,确认变化后再隐藏值。
     *
     * <p><b>为什么合成一个方法而不是在调用处各套两层</b>:UPDATE 路径的 beforeRow 与 afterRow
     * 必须经**完全相同**的剔除规则,否则单边少剔一列就会产出一条假 diff(该列在一侧存在、
     * 另一侧不存在)。把"两侧同规则"这件事交给代码结构保证,而不是靠调用处的注释约定
     * ——约定会在下一次改动里悄悄失效(同 L066)。
     *
     * <p><b>只用于 diff,不用于 {@code data_snapshot}</b>:快照保留完整行(含技术列),
     * 剔除只发生在“要不要给人看这条变更”这一层。
     *
     * <p>{@code static} + 包内可见是为了让 {@code AuditL0PartialSnapshotProbe} 直接驱动它:
     * “技术列被剔、脱敏列只隐藏值”这条口径的错法是静默地多出、少掉或泄露一列。
     */
    static Map<String, Object> forDiff(Map<String, Object> row) {
        return withoutTechColumns(row);
    }
 
    /** 比较同一次数据库写入前后的两份快照;单侧缺失字段按空值参与比较。 */
    static String databaseBoundaryDiff(Map<String, Object> beforeRow,
                                       Map<String, Object> afterRow,
                                       Set<String> masked,
                                       Map<String, AuditFieldDiff.FieldMeta> labels,
                                       Set<String> childFields) {
        Map<String, Object> before = withoutFields(forDiff(beforeRow), childFields);
        Map<String, Object> after = withoutFields(forDiff(afterRow), childFields);
        return AuditFieldDiff.diff(before, after, masked, labels);
    }
 
    /**
     * 剔除平台技术列后的副本。
     *
     * <p>判据默认是**前缀 {@code f_}**,依据是仓库 CLAUDE.md「Conventions」里的业务规范:
     * 业务字段禁止以 {@code f_} 开头,该前缀为平台技术列保留。全库实查坐实(2026-07-27):
     * lims 的 65 张表里带该前缀的列共 15 个,全部是平台列({@code f_id} / {@code f_tenant_id} /
     * {@code f_version} / {@code f_delete_mark} / {@code f_creator_time} / {@code f_flow_state} /
     * {@code f_foreign_id} 等),无一例业务字段。
     * 其中创建人、创建时间、最后修改人、最后修改时间是审计需要展示的操作责任信息,作为明确例外保留。
     *
     * <p>顺带剔除裸 {@code id}:前端提交时把主键同时放在 {@code f_id} 和 {@code id} 两个键上,
     * 后者是别名、语义重复。
     *
     * <p><b>为什么要剔</b>:CREATE 路径的 before 为 null,于是**每一列都算新增**,
     * 技术列会混进 field_diffs 变成噪音——{@code f_id} 与 {@code target_id} 列完全重复、
     * {@code f_version} 恒为 0。旧 lims listener 按 {@code @FieldLabel} 只记业务字段,
     * 本方法把这个口径补回来(2026-07-27,用户拍板"做干净点")。
     */
    private static Map<String, Object> withoutTechColumns(Map<String, Object> row) {
        if (row == null || row.isEmpty()) {
            return row;
        }
        Map<String, Object> out = new LinkedHashMap<>(row.size());
        for (Map.Entry<String, Object> entry : row.entrySet()) {
            if (!isTechColumn(entry.getKey())) {
                out.put(entry.getKey(), entry.getValue());
            }
        }
        return out;
    }
 
    /** 与 {@link #isMasked} 同口径按小写比,避免大小写混写的列名漏判。 */
    private static boolean isTechColumn(String column) {
        if (column == null) {
            return false;
        }
        String lower = column.toLowerCase(Locale.ROOT);
        return (lower.startsWith(TECH_COLUMN_PREFIX) && !AUDITED_PLATFORM_COLUMNS.contains(lower))
                || "id".equals(lower);
    }
 
    /** 与 {@code AuditFieldDiff} 同口径:脱敏清单是小写的,列名按小写比。 */
    private static boolean isMasked(String column, Set<String> masked) {
        return column != null && masked.contains(column.toLowerCase(Locale.ROOT));
    }
 
    /**
     * 签名凭证引用属于内建敏感字段,不依赖 Nacos 的 maskedFields 是否漏配。
     */
    private static Set<String> effectiveMasked(Set<String> configured, Map<String, Object> row,
                                               Map<String, AuditFieldDiff.FieldMeta> metadata) {
        Set<String> result = new java.util.LinkedHashSet<>();
        if (configured != null) {
            result.addAll(configured);
        }
        if (row != null) {
            for (String field : row.keySet()) {
                if (isCredentialReference(field)) {
                    result.add(field.toLowerCase(Locale.ROOT));
                }
            }
        }
        if (metadata != null) {
            for (Map.Entry<String, AuditFieldDiff.FieldMeta> entry : metadata.entrySet()) {
                if (entry.getKey() != null && AuditFieldDiff.isCredentialMetadata(entry.getValue())) {
                    result.add(entry.getKey().toLowerCase(Locale.ROOT));
                }
            }
        }
        return result;
    }
 
    public static boolean isCredentialReference(String field) {
        if (field == null) {
            return false;
        }
        String lower = field.trim().toLowerCase(Locale.ROOT);
        return "biz_sign".equals(lower)
                || "bizsign".equals(lower)
                || "sign_id".equals(lower)
                || "signid".equals(lower)
                || lower.endsWith("_sign")
                || field.endsWith("Sign")
                || field.endsWith("SignId");
    }
 
    /** 按小写字段名从事件行取值,兼容大小写混写的列名。 */
    private static Object lookup(Map<String, Object> row, String lowerField) {
        if (row == null || lowerField == null) {
            return null;
        }
        for (Map.Entry<String, Object> entry : row.entrySet()) {
            if (entry.getKey() != null
                    && lowerField.equals(entry.getKey().toLowerCase(Locale.ROOT))) {
                return entry.getValue();
            }
        }
        return null;
    }
 
    /**
     * 把事件跨 JVM 序列化后的值统一成展示用字符串。写入前后的两侧应用完全相同的规则,
     * 避免 JSON 数值、日期等运行时类型差异产生假 diff。用
     * {@link AuditFieldDiff#stringify} 保证最终展示值与层 1 的字段差异契约一致。
     */
    private Map<String, Object> stringifyValues(Map<String, Object> row) {
        if (row == null) {
            return null;
        }
        Map<String, Object> out = new LinkedHashMap<>(row.size());
        for (Map.Entry<String, Object> entry : row.entrySet()) {
            out.put(entry.getKey(), AuditFieldDiff.stringify(entry.getValue()));
        }
        return out;
    }
 
    /**
     * 事件 source 统一成 Map(L004)。跨 JVM 来的是 {@code JSONObject}(本身就是 Map);
     * 同 JVM 本地事件是 {@code VisualLogForm},走 {@code JSON.toJSON} 转过去。
     */
    @SuppressWarnings("unchecked")
    private Map<String, Object> asMap(Object source) {
        if (source == null) {
            return null;
        }
        if (source instanceof Map) {
            return (Map<String, Object>) source;
        }
        Object json = JSON.toJSON(source);
        return json instanceof Map ? (Map<String, Object>) json : null;
    }
 
    private static String str(Object value) {
        if (value == null) {
            return null;
        }
        String s = value.toString().trim();
        return s.isEmpty() ? null : s;
    }
 
    /**
     * 客户端显示值只能补充标题解析,不能决定标题字段。这里按发布表单主字段白名单、
     * 脱敏/签名排除集和固定长度上限收口,非法或多余字段直接丢弃。
     */
    private static Map<String, String> frontendDisplayValues(Object raw,
                                                              AuditTitleSpec spec,
                                                              Set<String> excludedFields) {
        if (!(raw instanceof Collection) || spec == null || spec.mainFields().isEmpty()) {
            return Collections.emptyMap();
        }
        Set<String> allowed = new LinkedHashSet<String>(spec.mainFields());
        Map<String, String> result = new LinkedHashMap<String, String>();
        int scanned = 0;
        for (Object item : (Collection<?>) raw) {
            if (++scanned > 50) {
                break;
            }
            if (!(item instanceof Map)) {
                continue;
            }
            Map<?, ?> value = (Map<?, ?>) item;
            String field = str(value.get("field"));
            String display = str(value.get("displayValue"));
            if (field == null || display == null) {
                continue;
            }
            String normalized = field.toLowerCase(Locale.ROOT);
            if (!allowed.contains(normalized) || containsIgnoreCase(excludedFields, normalized)) {
                continue;
            }
            display = display.replace('\r', ' ').replace('\n', ' ').trim();
            if (!display.isEmpty()) {
                result.put(normalized, display.length() <= 400 ? display : display.substring(0, 400));
            }
        }
        return result;
    }
 
    private static boolean containsIgnoreCase(Set<String> values, String expected) {
        if (values == null) {
            return false;
        }
        for (String value : values) {
            if (value != null && expected.equals(value.toLowerCase(Locale.ROOT))) {
                return true;
            }
        }
        return false;
    }
 
    /**
     * 一份签名候选:<b>值</b>({@code signId})、<b>来源</b>({@code fromRequest})、
     * <b>判定结果</b>({@code thisOperation})三者绑在一起走完全程。
     *
     * <p><b>为什么不是 {@code Map<String,String> signFieldToId}</b>(2026-08-05 Codex 复审 High①):
     * 那个结构把「来源」编码进了 map 的 <b>key</b>(伪字段名 {@link #REQUEST_SIGN_FIELD}),
     * 于是一个可碰撞的字符串同时承担了标识与授权两件事——业务表只要有同名列,数据列证据
     * 就会被当成请求来源改写 {@code action_code},且 {@code putAll} 会用数据列的值覆盖请求值。
     * 判定结果同理:原先记在一个「字段名集合」里,也是按名字装。名字不是身份,
     * 来源与判定都必须是候选自己的属性(L081:来源是属性,不能从内容反推)。
     *
     * <p>{@code signField} 只剩一个用途:落进 {@code signExtra.signField} / 历史引用给人看,
     * 以及当「哪些列不许进标题」的名单。任何判定都不许再读它。
     */
    private static final class SignCandidate {
        /** 数据列名;请求携带的那份是展示标签 {@link #REQUEST_SIGN_FIELD}。 */
        private final String signField;
        private final String signId;
        /** 是否来自请求顶层的 {@code bizSign}(Task 1 平台补丁)——唯一够格改写动作语义的来源。 */
        private final boolean fromRequest;
        /** 是不是「本次操作」签的,由 {@link #signedByThisOperation} 一处判定、下游共用。 */
        private boolean thisOperation;
 
        // 【曾经有过一个 authReject 字段,2026-08-05 当天撤掉了】——它给「签名人≠操作人」
        // 当拒收判据,而那个判据不成立(verify_user 会签合法且不可区分,见授权循环处的长注释)。
        // 撤掉而不是留着不用:死字段会让下一个人以为审计层做了授权判定,而它没有、也做不了。
 
        private SignCandidate(String signField, String signId, boolean fromRequest) {
            this.signField = signField;
            this.signId = signId;
            this.fromRequest = fromRequest;
        }
    }
 
    /**
     * AUDIT-P1 注入的请求上下文(平台补丁 #5,登记于 tasks/jnpf-platform-patches.md)。
     *
     * <p>发布方把它序列化成 JSON **字符串**挂在 form.auditCtx 上({@code ProjectEventBuilder}
     * 没有任何 extras 通道,只能挂在 form 自己的新增字段里)。取不到时走降级路径并**留痕**——
     * 静默降级等于「审计有洞而无人知道」。
     */
    private static final class AuditCtx {
        private String userId = UNKNOWN;
        private String userName;
        private String tenantId = AuditApiConsts.DEFAULT_TENANT_ID;
        private String ip;
        private String browser;
        private String os;
        private String uri;
        /** 前端路由识别出的应用编码与运行范围(FRONTEND/BACKEND/CONSOLE)。 */
        private String appCode;
        private String appScope;
        /** 用户点进来的菜单 id(AUDIT-P1 从 {@code Jnpf-Menu-Id} 请求头透传)。没带就是 null。 */
        private String menuId;
        /** 请求线程固化的 X-Audit-Biz-Sign;用于跨 HTTP/MQ 链路关联同一次前端操作。 */
        private String auditBizSign;
        private Date eventTime;
 
        /**
         * @param eventTenantId {@code ProjectEvent.tenantId}——事件框架原生的租户通道。
         *                      计划 §Task C1 与 spec §5.4 指定的就是它;auditCtx 是 AUDIT-P1
         *                      后来加的、信息更全的通道,故按「auditCtx → 事件原生 → 哨兵」回落。
         *                      单租户下两条都是空串、结果都回落 "0",差别只在多租户 + auditCtx
         *                      缺席(补丁未部署 / 在途旧消息)时——那时事件原生通道能救回真租户,
         *                      少了它就会把真租户写成 "0"(评审 I-4)。
         */
        static AuditCtx parse(Object raw, String eventTenantId, Map<String, Object> extra) {
            AuditCtx ctx = new AuditCtx();
            Map<String, Object> src = null;
            try {
                if (raw instanceof Map) {
                    src = castMap(raw);
                } else if (raw != null) {
                    String json = raw.toString().trim();
                    if (!json.isEmpty()) {
                        src = JsonUtil.stringToMap(json);
                    }
                }
            } catch (Throwable t) {
                src = null;   // 解析不了就按「没有上下文」处理,下面统一走降级
            }
            if (src == null || src.isEmpty()) {
                // 补丁未部署 / 在途的旧消息 / 非 Web 线程触发的保存(定时任务等)
                extra.put("whoSource", "degraded");
                extra.put("timeSource", "degraded");
                ctx.eventTime = new Date();
                ctx.applyTenant(null, eventTenantId);
                return ctx;
            }
            String userId = str(src.get("userId"));
            if (userId == null) {
                extra.put("whoSource", "degraded");
            } else {
                ctx.userId = userId;
            }
            ctx.userName = str(src.get("userName"));
            ctx.applyTenant(str(src.get("tenantId")), eventTenantId);
            ctx.ip = str(src.get("ip"));
            ctx.uri = str(src.get("uri"));
            ctx.appCode = str(src.get("appCode"));
            ctx.appScope = str(src.get("appScope"));
            ctx.menuId = str(src.get("menuId"));
            ctx.auditBizSign = str(src.get("auditBizSign"));
            ctx.parseUserAgent(str(src.get("userAgent")));
            // 操作时刻取自发布方(请求线程),而不是这里的消费时刻——两者之间隔着 MQ 投递,
            // 积压时能差出好几分钟,那会让审计时间线整体错位。
            Object opTime = src.get("opTime");
            if (opTime instanceof Number) {
                ctx.eventTime = new Date(((Number) opTime).longValue());
            } else {
                extra.put("timeSource", "degraded");
                ctx.eventTime = new Date();
            }
            return ctx;
        }
 
        /** 租户回落链:auditCtx.tenantId → ProjectEvent.tenantId → 哨兵(见 {@link #parse} 注释)。 */
        private void applyTenant(String fromAuditCtx, String fromEvent) {
            if (fromAuditCtx != null) {
                this.tenantId = fromAuditCtx;
            } else if (str(fromEvent) != null) {
                this.tenantId = str(fromEvent);
            }
            // 两者都空白时保持字段初始值 AuditApiConsts.DEFAULT_TENANT_ID
        }
 
        private void parseUserAgent(String ua) {
            if (ua == null) {
                return;
            }
            try {
                UserAgent agent = UserAgentUtil.parse(ua);
                if (agent != null) {
                    this.browser = join(agent.getBrowser().getName(), agent.getVersion());
                    this.os = join(agent.getPlatform().getName(), agent.getOsVersion());
                }
            } catch (Throwable ignore) {
                // UA 解析纯属锦上添花,失败不影响事件
            }
        }
 
        @SuppressWarnings("unchecked")
        private static Map<String, Object> castMap(Object raw) {
            return (Map<String, Object>) raw;
        }
 
        private static String join(String a, String b) {
            String x = a == null ? "" : a.trim();
            String y = b == null ? "" : b.trim();
            String r = (x + " " + y).trim();
            return r.isEmpty() ? null : r;
        }
    }
}