刘光辉
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
package jnpf.bizcommon.audit.service;
 
import jnpf.audit.diff.AuditFieldDiff;
import jnpf.bizcommon.audit.service.title.AuditListTitleSpecParser;
import jnpf.bizcommon.audit.service.title.AuditTitleSpec;
import jnpf.bizcommon.audit.service.title.AuditTitleSpecParser;
import jnpf.util.JsonUtil;
import jnpf.util.visiual.JnpfKeyConsts;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
 
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
 
/**
 * 层 0 表单注册表(Task C1):回答「这个在线表单要不要审计、算哪个业务模块」。
 *
 * <p>取代 lims 侧 {@code BizFormRegistry} 的 18 条硬编码静态 Map——那张表每接一个新表单就要改
 * lims 的代码、重新打包、重启服务,业务模块之间还会互相踩。现在改成 <b>Nacos 配置驱动</b>
 * (data-id {@code audit-layer0-forms.yaml}),各业务模块自助注册自己的表单,audit 服务不动代码。
 *
 * <p><b>按主表名注册,而不是按 modelId</b>:modelId 是无意义的雪花数字({@code 844231923957086149}),
 * 写进配置没人看得懂也没人敢改;而且同一张业务主表常有多个表单视图(列表页/详情页/移动端各一个
 * modelId),按表登记只需一条。modelId → 主表名这一跳仍走 JDBC 反查 {@code base_visual_release}
 * 并缓存,与旧实现同口径——A/B 比对时少一个变量。
 *
 * <p>是否只按清单审计由 {@code includeUnregisteredForms} 决定。开启后,允许的应用前台中所有在线
 * 表单都纳入审计;清单继续提供模块、业务类型、脱敏字段等更准确的元数据。
 */
@Slf4j
@Component
@ConfigurationProperties(prefix = "audit.layer0")
public class AuditFormRegistry {
 
    /**
     * 层 0 总开关。默认关——与层 1 同纪律:新埋点默认不上,按表灰度开。
     * volatile:Nacos 刷新线程写、MQ 消费线程读(评审 M-1)。
     */
    private volatile boolean enabled = false;
 
    /** 仅接受前端应用前台请求;旧消息没有 appScope 时按非前台处理。 */
    private volatile boolean frontendOnly = false;
 
    /** READ 日志独立开关,默认关闭以避免列表轮询产生大量日志。 */
    private volatile boolean recordRead = false;
 
    /** 允许的前端应用编码;空集合表示不限制具体应用。 */
    private List<String> allowedAppCodes = new ArrayList<>();
    private volatile Set<String> allowedAppCodeSet = Collections.emptySet();
 
    /** true 时,应用前台访问的未显式登记在线表单也纳入审计。 */
    private volatile boolean includeUnregisteredForms = false;
 
    /**
     * 注册清单,由 Nacos 配置注入。
     *
     * <p><b>形态必须是 List 而不是 Map&lt;表名, 元数据&gt;</b>(C1 实测踩到):Spring Boot Binder
     * 对 Map 属性做的是<b>合并</b>——刷新时它先调 getter 拿到现有 Map,再把新配置 put 进去,
     * 于是<b>被删掉的表单永远留在注册表里</b>,「从清单移除一张表」只有重启才生效。
     * 审计范围只能加不能减,是运营上的真隐患(实测:撤回配置后那张表照记不误)。
     * 索引集合(List)的绑定则是整体替换,setter 每次都拿到完整的新清单,增删都即时生效。
     */
    private List<FormMeta> forms = new ArrayList<>();
 
    /** 主表名(小写)→ 元数据。由 {@link #setForms} 从清单重建,每次刷新整体替换。 */
    private volatile Map<String, FormMeta> formsByTable = Collections.emptyMap();
 
    /**
     * modelId → 主表名 的运行时缓存。
     *
     * <p><b>只缓存确定性结果</b>(空串占位表示「确定地查不到」):查得到发布记录但里面没有表、
     * 或那段 JSON 根本不合法——重试一万次也一样。**瞬时故障一律不缓存**(连接抖动、超时、
     * OOM/SO)——L061 ①:缓存"失败"必须区分确定性与瞬时,而且必须用白名单判定。
     * 这里的缓存是进程级、无过期的,一次连接抖动若被记成「这个 modelId 没有主表」,
     * 该表单从此到重启前**永远不被审计**,而且日志里什么都没有。
     *
     * <p>缓存使用有限 TTL;注册表热刷新和 TTL 配置变化还会主动清空。表单重新发布但未触发
     * 注册表刷新时,最迟在 TTL 到期后重新读取,避免历史查询长期绑定旧配置。
     */
    private final Map<String, CacheValue<String>> modelIdToMainTable = new ConcurrentHashMap<>();
 
    /** modelId → 已发布表单字段元数据;只缓存确定性查询/解析结果。 */
    private final Map<String, CacheValue<Map<String, AuditFieldDiff.FieldMeta>>> modelIdToFieldMeta =
            new ConcurrentHashMap<>();
 
    /** modelId → 标题勾选配置;与字段元数据同一套 TTL / 只缓存确定性结果的口径。 */
    private final Map<String, CacheValue<AuditTitleSpec>> modelIdToTitleSpec =
            new ConcurrentHashMap<>();
 
    /** 列表标题规格缓存(D6)。与 modelIdToTitleSpec 分开:同一 modelId 两种口径不能共用一格。 */
    private final Map<String, CacheValue<AuditTitleSpec>> modelIdToListTitleSpec =
            new ConcurrentHashMap<>();
 
    /** 签名字段配置缓存(D4)。 */
    private final Map<String, CacheValue<List<String>>> modelIdToSignFields =
            new ConcurrentHashMap<>();
 
    /** 数据库字段备注缓存;查询异常时不写缓存。 */
    private final Map<String, CacheValue<Map<String, AuditFieldDiff.FieldMeta>>> tableToCommentMeta =
            new ConcurrentHashMap<>();
 
    /** 发布表单、主表反查及 COMMENT 元数据缓存 TTL;Nacos 刷新注册表时还会主动清空。 */
    private volatile long metadataCacheTtlMs = 300_000L;
 
    /** 反查不到主表的 modelId 只告警一次,避免高频表单把日志刷爆。 */
    private final Set<String> warnedModels = ConcurrentHashMap.newKeySet();
 
    @Autowired
    private JdbcTemplate jdbcTemplate;
 
    /**
     * 一个在线表单在审计里的身份。
     * 除 {@code table} 外字段全部可选,缺了只是记得糙一点,不影响事件是否落库。
     */
    @Data
    public static class FormMeta {
        /** 业务主表名(清单的主键)。缺失或空白的条目会被 {@link #setForms} 丢弃并告警。 */
        private String table;
        /** 业务模块语义,落 {@code audit_events.biz_module},如「环境监测-区域管理」。 */
        private String bizModule;
        /** 业务分类,落 {@code biz_type},与旧 {@code BizLogType} 同位。 */
        private String bizType;
        /** 从表单数据里取业务单号的字段名,落 {@code biz_code};取不到就留空,**不丢事件**。 */
        private String bizCodeField;
        /** 脱敏字段(小写):只记「已变更」不记值,与层 1 的 masked-columns 同语义。 */
        private Set<String> maskedFields = Collections.emptySet();
    }
 
    /**
     * 启动完成后打印一次**权威**生效态。
     *
     * <p>{@link #setForms} 里那行也打了 enabled,但属性绑定时 setter 的调用顺序不定,
     * 那一行可能早于 {@link #setEnabled} 而显示上一拍的值。这里在绑定全部完成后再报一次,
     * 排查「到底开没开」时以这行为准。
     */
    @jakarta.annotation.PostConstruct
    public void logEffectiveState() {
        log.info("[AUDIT-L0] 层0 生效态:enabled={},注册表 {} 张表 {}",
                enabled, formsByTable.size(), formsByTable.keySet());
    }
 
    public boolean isEnabled() {
        return enabled;
    }
 
    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }
 
    public boolean isRecordRead() {
        return recordRead;
    }
 
    public void setRecordRead(boolean recordRead) {
        this.recordRead = recordRead;
    }
 
    public boolean isFrontendOnly() {
        return frontendOnly;
    }
 
    public void setFrontendOnly(boolean frontendOnly) {
        this.frontendOnly = frontendOnly;
    }
 
    public boolean isIncludeUnregisteredForms() {
        return includeUnregisteredForms;
    }
 
    public void setIncludeUnregisteredForms(boolean includeUnregisteredForms) {
        this.includeUnregisteredForms = includeUnregisteredForms;
        clearMetadataCaches();
    }
 
    public List<String> getAllowedAppCodes() {
        return allowedAppCodes;
    }
 
    public void setAllowedAppCodes(List<String> allowedAppCodes) {
        this.allowedAppCodes = allowedAppCodes == null ? new ArrayList<>() : allowedAppCodes;
        Set<String> normalized = new LinkedHashSet<>();
        for (String code : this.allowedAppCodes) {
            if (code != null && !code.trim().isEmpty()) {
                normalized.add(code.trim().toUpperCase(Locale.ROOT));
            }
        }
        this.allowedAppCodeSet = normalized.isEmpty()
                ? Collections.emptySet() : Collections.unmodifiableSet(normalized);
    }
 
    /** 判断请求来源是否属于配置允许的应用前台。 */
    public boolean acceptsRequest(String appScope, String appCode) {
        if (frontendOnly && !"FRONTEND".equalsIgnoreCase(appScope == null ? "" : appScope.trim())) {
            return false;
        }
        if (allowedAppCodeSet.isEmpty()) {
            return true;
        }
        return appCode != null
                && allowedAppCodeSet.contains(appCode.trim().toUpperCase(Locale.ROOT));
    }
 
    public long getMetadataCacheTtlMs() {
        return metadataCacheTtlMs;
    }
 
    public void setMetadataCacheTtlMs(long metadataCacheTtlMs) {
        this.metadataCacheTtlMs = Math.max(0L, metadataCacheTtlMs);
        clearMetadataCaches();
    }
 
    public List<FormMeta> getForms() {
        return forms;
    }
 
    /**
     * 从清单重建查找表,**整体替换**(这正是用 List 的意义,见 {@link #forms} 注释)。
     *
     * <p>表名与脱敏字段一律归一成小写:PG 里的表名是小写,而配置是手写的(大小写、空格随人),
     * 在入口处统一一次,比在每个查询点各写一次 toLowerCase 可靠——漏一处就是脱敏静默失效。
     */
    public void setForms(List<FormMeta> forms) {
        this.forms = forms == null ? new ArrayList<>() : forms;
        Map<String, FormMeta> rebuilt = new LinkedHashMap<>();
        for (FormMeta meta : this.forms) {
            if (meta == null) {
                continue;
            }
            String table = meta.getTable() == null ? null : lower(meta.getTable().trim());
            if (table == null || table.isEmpty()) {
                // 没有 table 的条目无从匹配,静默丢弃就成了「配了却不生效」的哑谜
                log.error("[AUDIT-L0] 注册表里有一条缺 table 的记录,已忽略:{}", meta);
                continue;
            }
            meta.setTable(table);   // 归一化写回,免得将来有人用 meta.getTable() 拿到大小写不一的原值
            meta.setMaskedFields(lowerAll(meta.getMaskedFields()));
            rebuilt.put(table, meta);
        }
        this.formsByTable = rebuilt;
        clearMetadataCaches();
        // **总开关一并打印**(评审 M-5):只报「载入了几张表」的话,关态与「一张没配」在日志上
        // 长得一模一样,排查时分不出是没配置还是被关掉了。
        log.info("[AUDIT-L0] 表单注册表已载入 {} 张表(层0 总开关 enabled={}):{}",
                rebuilt.size(), enabled, rebuilt.keySet());
    }
 
    /**
     * 解析 modelId 对应的审计元数据;未注册(或反查不到主表)返回 null = 不审计该表单。
     */
    public FormMeta resolve(String modelId) {
        String table = resolveMainTable(modelId);
        if (table == null) {
            return null;
        }
        FormMeta configured = formsByTable.get(table);
        if (configured != null || !includeUnregisteredForms) {
            return configured;
        }
        FormMeta fallback = new FormMeta();
        fallback.setTable(table);
        return fallback;
    }
 
    /**
     * 查询在线开发表单名称;查不到或查询异常时返回 null,由调用方沿用原 bizModule 回退逻辑。
     */
    public String resolveFullName(String modelId) {
        if (modelId == null || modelId.trim().isEmpty()) {
            return null;
        }
        String normalizedModelId = modelId.trim();
        try {
            String fullName = jdbcTemplate.queryForObject(
                    "SELECT f_full_name FROM base_visual_dev WHERE f_id = ?",
                    String.class, normalizedModelId);
            return fullName == null || fullName.trim().isEmpty() ? null : fullName.trim();
        } catch (org.springframework.dao.EmptyResultDataAccessException e) {
            return null;
        } catch (Throwable t) {
            log.warn("[AUDIT-L0] 反查在线开发表单名称失败,继续使用原 bizModule modelId={} err={}",
                    normalizedModelId, t.toString());
            return null;
        }
    }
 
    /**
     * 取当前已发布表单的字段元数据,数据库字段备注仅补表单中缺失的字段。
     * 表名必须在层 0 注册表白名单内,避免元数据 SQL 被任意表名驱动。
     */
    public Map<String, AuditFieldDiff.FieldMeta> resolveFieldMetadata(String modelId, String table) {
        String normalizedTable = table == null ? null : lower(table.trim());
        if (normalizedTable == null
                || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable))) {
            return Collections.emptyMap();
        }
        Map<String, AuditFieldDiff.FieldMeta> comments = commentMetadata(normalizedTable);
        if (modelId == null || modelId.trim().isEmpty()) {
            return comments;
        }
        // 查询历史事件时 sourceModelId 来自事件 extra,不能假定它一定属于 targetTable。
        // 不匹配时只保留当前表 COMMENT,避免用另一张表的发布元数据错误装饰字段名。
        if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) {
            return comments;
        }
        Map<String, AuditFieldDiff.FieldMeta> published = publishedFieldMetadata(modelId);
        return mergeFieldMetadata(comments, published);
    }
 
    /**
     * CREATE 建立**完整** baseline 用的「已知可审计字段」集合(小写)。
     *
     * <p><b>为什么需要这个</b>:平台把值为空的字段整个从事件的 {@code newData} 里剥掉了
     * (2026-07-30 实测:{@code lims_qingyandan} 的 CREATE 快照 21 键 vs 表单模型 23 字段,
     * 少的正是建单时留空的 {@code youxiaoqi} / {@code piliang} / {@code piliang_danwei})。
     * 于是 baseline 天生缺这些键,用户第一次把它们从空填成非空时,UPDATE 的两侧交集会把它们
     * 排除掉——**真实变更被漏记**。CREATE 时按本方法把这些字段显式补成空值即可闭合。
     *
     * <p><b>为什么是「已发布表单字段 ∩ 主表物理列」这个交集,而不是任一单侧</b>:
     * <ul>
     *   <li>只用物理列 → 会把**后端代码写、不经表单**的列(如 {@code jieshouren})也宣称为
     *       「建单时已知为空」。那些列的变更不产生层 0 事件,baseline 会长期停在假的空值上,
     *       下次表单碰到它就编出一条 {@code ""→X} 的假 diff——正是 B3 划的「没有 before
     *       不许给 field_diffs」那条红线。</li>
     *   <li>只用表单字段 → 会带进子表 children 的 vModel({@link #collectFields} 是递归的),
     *       那些不是主表的列,补进主表快照只是噪音。</li>
     * </ul>
     * 取交集后剩下的正是「这张表单控制、且确实是主表一列」的字段——建单提交的就是整份表单,
     * 没被携带即等于留空,这个推断有据可依。
     *
     * <p><b>任一侧取不到就返回空集</b>(非 PG 部署、元数据查询瞬时失败、modelId 与 table 对不上):
     * 不补 = 退化成今天的「baseline 缺字段 → 显式标记不可判定」,是安全方向。
     * 宁可继续少记一条,也不能凭猜造出一个旧值。
     */
    public Set<String> resolveBaselineFields(String modelId, String table) {
        String normalizedTable = table == null ? null : lower(table.trim());
        if ((normalizedTable == null
                || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable)))
                || modelId == null || modelId.trim().isEmpty()) {
            return Collections.emptySet();
        }
        // 与 resolveFieldMetadata 同一道闸:不能用另一张表的发布模型给当前表补 baseline
        if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) {
            return Collections.emptySet();
        }
        Map<String, AuditFieldDiff.FieldMeta> published = publishedFieldMetadata(modelId.trim());
        Map<String, AuditFieldDiff.FieldMeta> columns = commentMetadata(normalizedTable);
        if (published.isEmpty() || columns.isEmpty()) {
            return Collections.emptySet();
        }
        Set<String> fields = new LinkedHashSet<>();
        for (String field : published.keySet()) {
            if (columns.containsKey(field)) {
                fields.add(field);
            }
        }
        return fields.isEmpty() ? Collections.emptySet() : Collections.unmodifiableSet(fields);
    }
 
    /**
     * 返回物理表真实列名(小写)。只使用 JDBC 元数据,不拼接 SQL;解析失败返回空集,
     * 调用方据此选择保留原始审计数据,不能把元数据瞬时故障误判成“表中没有任何字段”。
     */
    public Set<String> resolvePhysicalColumns(String table) {
        String normalized = table == null ? null : lower(table.trim());
        if (normalized == null || !normalized.matches("[a-z0-9_]+")) {
            return Collections.emptySet();
        }
        Map<String, AuditFieldDiff.FieldMeta> columns = commentMetadata(normalized);
        return columns.isEmpty() ? Collections.emptySet()
                : Collections.unmodifiableSet(new LinkedHashSet<>(columns.keySet()));
    }
 
    /**
     * 取当前已发布表单的「操作记录标题」勾选配置。
     *
     * <p>两道闸与 {@link #resolveFieldMetadata} 完全相同,不能松:
     * <ul>
     *   <li>表名必须在层 0 注册表白名单内——否则元数据 SQL 会被任意表名驱动;</li>
     *   <li>modelId 反查出的主表必须与 {@code table} 一致——查询历史事件时
     *       {@code sourceModelId} 来自事件 extra,不能假定它属于当前表;
     *       用另一张表的发布模型解析标题,会把别的表的字段名当成本表的勾选。</li>
     * </ul>
     * 任一条不满足返回 {@link AuditTitleSpec#EMPTY} = 不产出标题(安全方向)。
     */
    public AuditTitleSpec resolveTitleSpec(String modelId, String table) {
        String normalizedTable = table == null ? null : lower(table.trim());
        if ((normalizedTable == null
                || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable)))
                || modelId == null || modelId.trim().isEmpty()) {
            return AuditTitleSpec.EMPTY;
        }
        if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) {
            return AuditTitleSpec.EMPTY;
        }
        return publishedTitleSpec(modelId.trim());
    }
 
    private AuditTitleSpec publishedTitleSpec(String modelId) {
        AuditTitleSpec cached = cachedValue(modelIdToTitleSpec, modelId);
        if (cached != null) {
            return cached;
        }
        String formData;
        try {
            formData = jdbcTemplate.queryForObject(
                    "SELECT F_FORM_DATA FROM base_visual_release WHERE F_ID = ?",
                    String.class, modelId);
        } catch (org.springframework.dao.EmptyResultDataAccessException e) {
            cacheValue(modelIdToTitleSpec, modelId, AuditTitleSpec.EMPTY);
            return AuditTitleSpec.EMPTY;
        } catch (Throwable t) {
            // 瞬时故障不缓存(L061 ①):缓存了等于该表单到重启前永远没有标题
            log.error("[AUDIT-L0] 读取表单标题配置失败(不缓存)modelId={}", modelId, t);
            return AuditTitleSpec.EMPTY;
        }
        AuditTitleSpec spec = AuditTitleSpecParser.parse(formData);
        cacheValue(modelIdToTitleSpec, modelId, spec);
        return spec;
    }
 
    /**
     * 解析列表场景的标题规格(D6):读 {@code F_COLUMN_DATA} 的
     * {@code columnList[].biz_log_enabled},字段编码取 {@code prop}。
     *
     * <p>与 {@link #resolveTitleSpec} 同样的两道闸,理由也一样:
     * 表名须在层 0 注册表白名单内(否则 SQL 被任意表名驱动);modelId 反查出的主表
     * 须与 {@code table} 一致(否则会拿另一张表的发布模型解析本表的勾选)。
     */
    public AuditTitleSpec resolveListTitleSpec(String modelId, String table) {
        String normalizedTable = table == null ? null : lower(table.trim());
        if ((normalizedTable == null
                || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable)))
                || modelId == null || modelId.trim().isEmpty()) {
            return AuditTitleSpec.EMPTY;
        }
        if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) {
            return AuditTitleSpec.EMPTY;
        }
        return publishedListTitleSpec(modelId.trim());
    }
 
    private AuditTitleSpec publishedListTitleSpec(String modelId) {
        AuditTitleSpec cached = cachedValue(modelIdToListTitleSpec, modelId);
        if (cached != null) {
            return cached;
        }
        String columnData;
        try {
            columnData = jdbcTemplate.queryForObject(
                    "SELECT F_COLUMN_DATA FROM base_visual_release WHERE F_ID = ?",
                    String.class, modelId);
        } catch (org.springframework.dao.EmptyResultDataAccessException e) {
            cacheValue(modelIdToListTitleSpec, modelId, AuditTitleSpec.EMPTY);
            return AuditTitleSpec.EMPTY;
        } catch (Throwable t) {
            // 瞬时故障不缓存(L061 ①):缓存了等于该表单到重启前永远没有列表标题
            log.error("[AUDIT-L0] 读取列表标题配置失败(不缓存)modelId={}", modelId, t);
            return AuditTitleSpec.EMPTY;
        }
        AuditTitleSpec spec = AuditListTitleSpecParser.parse(columnData);
        cacheValue(modelIdToListTitleSpec, modelId, spec);
        return spec;
    }
 
    /**
     * 解析表单配置里显式指定的签名字段(D4 权威来源)。
     *
     * <p>{@code confirmBtnConfig.biz_sign_field}(确定按钮)与
     * {@code reviewBtnConfig.biz_sign_field}(复核按钮)。多人签名场景下字段名可以是任意的,
     * 光靠 {@code _sign} 命名规则会漏——这就是为什么必须读配置而不是只靠猜。
     * 实测「测试表单」两者分别是 {@code biz_sign} 与 {@code fuhe_sign}。
     *
     * <p>返回小写字段名,与 {@link jnpf.bizcommon.audit.service.sign.AuditSignEvidenceReader#candidateFields}
     * 的口径一致。解析不出返回空列表(调用方自会退回命名规则+撞库兜底),不抛异常。
     */
    public List<String> resolveSignFields(String modelId, String table) {
        String normalizedTable = table == null ? null : lower(table.trim());
        if ((normalizedTable == null
                || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable)))
                || modelId == null || modelId.trim().isEmpty()) {
            return Collections.emptyList();
        }
        if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) {
            return Collections.emptyList();
        }
        return publishedSignFields(modelId.trim());
    }
 
    private List<String> publishedSignFields(String modelId) {
        List<String> cached = cachedValue(modelIdToSignFields, modelId);
        if (cached != null) {
            return cached;
        }
        String formData;
        try {
            formData = jdbcTemplate.queryForObject(
                    "SELECT F_FORM_DATA FROM base_visual_release WHERE F_ID = ?",
                    String.class, modelId);
        } catch (org.springframework.dao.EmptyResultDataAccessException e) {
            cacheValue(modelIdToSignFields, modelId, Collections.<String>emptyList());
            return Collections.emptyList();
        } catch (Throwable t) {
            // 瞬时故障不缓存(L061 ①)
            log.error("[AUDIT-L0] 读取签名字段配置失败(不缓存)modelId={}", modelId, t);
            return Collections.emptyList();
        }
        List<String> fields = parseSignFields(formData);
        cacheValue(modelIdToSignFields, modelId, fields);
        return fields;
    }
 
    /** 从表单根对象读两个按钮配置的 biz_sign_field。永不抛异常,解析不出返回空表。 */
    private static List<String> parseSignFields(String formDataJson) {
        if (formDataJson == null || formDataJson.trim().isEmpty()) {
            return Collections.emptyList();
        }
        try {
            Map<String, Object> root = JsonUtil.stringToMap(formDataJson);
            if (root == null) {
                return Collections.emptyList();
            }
            List<String> out = new ArrayList<>();
            String[] configKeys = {"confirmBtnConfig", "reviewBtnConfig"};
            for (String key : configKeys) {
                Object raw = root.get(key);
                if (!(raw instanceof Map)) {
                    continue;
                }
                Object field = ((Map<?, ?>) raw).get("biz_sign_field");
                if (field == null) {
                    continue;
                }
                String name = lower(String.valueOf(field).trim());
                if (name != null && !name.isEmpty() && !out.contains(name)) {
                    out.add(name);
                }
            }
            return out.isEmpty() ? Collections.<String>emptyList()
                    : Collections.unmodifiableList(out);
        } catch (Throwable t) {
            return Collections.emptyList();
        }
    }
 
    /** preferred 按字段名(忽略大小写)覆盖 fallback。 */
    public static Map<String, AuditFieldDiff.FieldMeta> mergeFieldMetadata(
            Map<String, AuditFieldDiff.FieldMeta> fallback,
            Map<String, AuditFieldDiff.FieldMeta> preferred) {
        if ((fallback == null || fallback.isEmpty()) && (preferred == null || preferred.isEmpty())) {
            return Collections.emptyMap();
        }
        Map<String, AuditFieldDiff.FieldMeta> merged = new LinkedHashMap<>();
        putAllNormalized(merged, fallback);
        if (preferred != null) {
            for (Map.Entry<String, AuditFieldDiff.FieldMeta> entry : preferred.entrySet()) {
                String field = lower(entry.getKey());
                if (field == null || entry.getValue() == null) {
                    continue;
                }
                merged.put(field, mergeFieldMeta(merged.get(field), entry.getValue()));
            }
        }
        return merged;
    }
 
    private Map<String, AuditFieldDiff.FieldMeta> publishedFieldMetadata(String modelId) {
        if (modelId == null || modelId.trim().isEmpty()) {
            return Collections.emptyMap();
        }
        String key = modelId.trim();
        Map<String, AuditFieldDiff.FieldMeta> cached = cachedValue(modelIdToFieldMeta, key);
        if (cached != null) {
            return cached;
        }
 
        String formData;
        try {
            formData = jdbcTemplate.queryForObject(
                    "SELECT F_FORM_DATA FROM base_visual_release WHERE F_ID = ?",
                    String.class, key);
        } catch (org.springframework.dao.EmptyResultDataAccessException e) {
            cacheValue(modelIdToFieldMeta, key, Collections.emptyMap());
            return Collections.emptyMap();
        } catch (Throwable t) {
            log.error("[AUDIT-L0] 读取发布表单元数据失败(不缓存)modelId={}", key, t);
            return Collections.emptyMap();
        }
 
        try {
            Map<String, AuditFieldDiff.FieldMeta> parsed = parsePublishedFieldMetadata(formData);
            Map<String, AuditFieldDiff.FieldMeta> immutable = parsed.isEmpty()
                    ? Collections.emptyMap() : Collections.unmodifiableMap(parsed);
            cacheValue(modelIdToFieldMeta, key, immutable);
            return immutable;
        } catch (Throwable t) {
            if (deterministicParseFailure(t) || t instanceof RuntimeException) {
                log.warn("[AUDIT-L0] 发布表单元数据无法解析(已缓存空结果)modelId={} err={}",
                        key, t.toString());
                cacheValue(modelIdToFieldMeta, key, Collections.emptyMap());
            } else {
                log.error("[AUDIT-L0] 解析发布表单元数据失败(不缓存)modelId={}", key, t);
            }
            return Collections.emptyMap();
        }
    }
 
    private Map<String, AuditFieldDiff.FieldMeta> commentMetadata(String table) {
        Map<String, AuditFieldDiff.FieldMeta> cached = cachedValue(tableToCommentMeta, table);
        if (cached != null) {
            return cached;
        }
        try (Connection connection = jdbcTemplate.getDataSource().getConnection()) {
            Map<String, AuditFieldDiff.FieldMeta> parsed = new LinkedHashMap<>();
            DatabaseMetaData metadata = connection.getMetaData();
            String catalog = connection.getCatalog();
            String schema = currentSchema(connection);
            collectColumnMetadata(metadata, catalog, schema, table, parsed);
            if (parsed.isEmpty() && schema != null) {
                collectColumnMetadata(metadata, catalog, null, table, parsed);
            }
            if (parsed.isEmpty()) {
                collectColumnMetadata(metadata, null, schema, table, parsed);
            }
            if (parsed.isEmpty()) {
                collectColumnMetadata(metadata, catalog, schema,
                        table.toUpperCase(Locale.ROOT), parsed);
            }
            Map<String, AuditFieldDiff.FieldMeta> immutable = parsed.isEmpty()
                    ? Collections.emptyMap() : Collections.unmodifiableMap(parsed);
            cacheValue(tableToCommentMeta, table, immutable);
            return immutable;
        } catch (Throwable t) {
            // 元数据驱动不返回备注或短暂连接故障:不阻断事件,也不缓存失败。
            log.warn("[AUDIT-L0] 读取字段 COMMENT 失败(不缓存)table={} err={}", table, t.toString());
            return Collections.emptyMap();
        }
    }
 
    private static String currentSchema(Connection connection) {
        try {
            return connection.getSchema();
        } catch (Throwable ignored) {
            return null;
        }
    }
 
    private static void collectColumnMetadata(DatabaseMetaData metadata,
                                              String catalog,
                                              String schema,
                                              String table,
                                              Map<String, AuditFieldDiff.FieldMeta> target) throws Exception {
        try (ResultSet columns = metadata.getColumns(catalog, schema, table, null)) {
            while (columns.next()) {
                String field = str(columns.getObject("COLUMN_NAME"));
                if (field == null) {
                    continue;
                }
                String label = str(columns.getObject("REMARKS"));
                String jdbcType = str(columns.getObject("TYPE_NAME"));
                putNormalized(target, field, jdbcFieldMeta(label, jdbcType));
            }
        }
    }
 
    /** 纯解析入口,供针对性探针覆盖发布表单的嵌套 fields 结构。 */
    @SuppressWarnings({"rawtypes", "unchecked"})
    static Map<String, AuditFieldDiff.FieldMeta> parsePublishedFieldMetadata(String formDataJson) {
        if (formDataJson == null || formDataJson.trim().isEmpty()) {
            return Collections.emptyMap();
        }
        Map<String, Object> root = JsonUtil.stringToMap(formDataJson);
        if (root == null) {
            return Collections.emptyMap();
        }
        Object rawFields = root.get("fields");
        List<Map> fields;
        if (rawFields instanceof List) {
            fields = (List<Map>) rawFields;
        } else if (rawFields != null) {
            fields = JsonUtil.getJsonToList(String.valueOf(rawFields), Map.class);
        } else {
            fields = Collections.emptyList();
        }
        Map<String, AuditFieldDiff.FieldMeta> result = new LinkedHashMap<>();
        collectFields(fields, result);
        return result;
    }
 
    @SuppressWarnings({"rawtypes", "unchecked"})
    private static void collectFields(List<?> fields, Map<String, AuditFieldDiff.FieldMeta> result) {
        if (fields == null) {
            return;
        }
        for (Object raw : fields) {
            if (!(raw instanceof Map)) {
                continue;
            }
            Map item = (Map) raw;
            Object rawConfig = item.containsKey("config") ? item.get("config") : item.get("__config__");
            if (!(rawConfig instanceof Map)) {
                continue;
            }
            Map config = (Map) rawConfig;
            String field = str(item.containsKey("vModel") ? item.get("vModel") : item.get("__vModel__"));
            String label = Boolean.TRUE.equals(bool(config.get("noShow"))) ? null : str(config.get("label"));
            String jnpfKey = str(config.get("jnpfKey"));
            String dataType = str(config.get("dataType"));
            String dictionaryType = str(config.get("dictionaryType"));
            Boolean hidden = bool(config.get("noShow"));
            if (field != null) {
                boolean nameModified = jnpfKey != null
                        && JnpfKeyConsts.getNameModified().contains(jnpfKey)
                        && (JnpfKeyConsts.getNameModifiedNotDynamic().contains(jnpfKey)
                        || "dynamic".equals(dataType));
                Map<String, String> options = optionLabels(item.get("options"), item.get("props"));
                String componentType = componentType(jnpfKey);
                String valueType = valueType(jnpfKey, dataType, options, bool(item.get("multiple")));
                String format = firstText(item.get("format"), config.get("format"));
                putNormalized(result, field,
                        new AuditFieldDiff.FieldMeta(label, jnpfKey, nameModified,
                                valueType, componentType, format, options,
                                dictionaryType, hidden));
            }
            Object children = config.get("children");
            if (children instanceof List) {
                collectFields((List<?>) children, result);
            } else if (children != null && !String.valueOf(children).trim().isEmpty()) {
                collectFields(JsonUtil.getJsonToList(String.valueOf(children), Map.class), result);
            }
        }
    }
 
    private static void putAllNormalized(Map<String, AuditFieldDiff.FieldMeta> target,
                                         Map<String, AuditFieldDiff.FieldMeta> source) {
        if (source == null) {
            return;
        }
        for (Map.Entry<String, AuditFieldDiff.FieldMeta> entry : source.entrySet()) {
            putNormalized(target, entry.getKey(), entry.getValue());
        }
    }
 
    private static void putNormalized(Map<String, AuditFieldDiff.FieldMeta> target,
                                      String field,
                                      AuditFieldDiff.FieldMeta meta) {
        if (field == null || meta == null) {
            return;
        }
        String normalized = lower(field.trim());
        if (normalized != null && !normalized.isEmpty()) {
            target.put(normalized, meta);
        }
    }
 
    private static AuditFieldDiff.FieldMeta mergeFieldMeta(
            AuditFieldDiff.FieldMeta fallback, AuditFieldDiff.FieldMeta preferred) {
        if (fallback == null) {
            return preferred;
        }
        if (preferred == null) {
            return fallback;
        }
        Boolean hidden = preferred.hidden() != null ? preferred.hidden() : fallback.hidden();
        return new AuditFieldDiff.FieldMeta(
                Boolean.TRUE.equals(hidden) ? fallback.label()
                        : firstText(preferred.label(), fallback.label()),
                firstText(preferred.jnpfKey(), fallback.jnpfKey()),
                preferred.nameModified() != null ? preferred.nameModified() : fallback.nameModified(),
                firstText(preferred.valueType(), fallback.valueType()),
                firstText(preferred.componentType(), fallback.componentType()),
                firstText(preferred.format(), fallback.format()),
                preferred.optionLabels().isEmpty() ? fallback.optionLabels() : preferred.optionLabels(),
                firstText(preferred.dictionaryType(), fallback.dictionaryType()),
                hidden);
    }
 
    private static AuditFieldDiff.FieldMeta jdbcFieldMeta(String label, String jdbcType) {
        String type = lower(jdbcType);
        String valueType = null;
        String componentType = null;
        if (type != null) {
            if (type.startsWith("timestamp")) {
                valueType = "datetime";
                componentType = "datePicker";
            } else if (type.equals("date")) {
                valueType = "date";
                componentType = "datePicker";
            } else if (type.startsWith("bool")) {
                valueType = "boolean";
                componentType = "switch";
            } else if (type.startsWith("smallint") || type.startsWith("integer")
                    || type.startsWith("bigint") || type.startsWith("numeric")
                    || type.startsWith("decimal") || type.startsWith("real")
                    || type.startsWith("double")) {
                valueType = "number";
                componentType = "inputNumber";
            } else if (type.startsWith("json") || type.endsWith("[]")) {
                valueType = "json";
                componentType = "input";
            }
        }
        return new AuditFieldDiff.FieldMeta(label, null, null,
                valueType, componentType, null, Collections.emptyMap());
    }
 
    @SuppressWarnings({"rawtypes", "unchecked"})
    private static Map<String, String> optionLabels(Object rawOptions, Object rawProps) {
        List<?> options;
        if (rawOptions instanceof List) {
            options = (List<?>) rawOptions;
        } else if (rawOptions == null || String.valueOf(rawOptions).trim().isEmpty()) {
            return Collections.emptyMap();
        } else {
            options = JsonUtil.getJsonToList(String.valueOf(rawOptions), Map.class);
        }
        Map props = rawProps instanceof Map ? (Map) rawProps : Collections.emptyMap();
        String labelKey = firstText(props.get("label"), "fullName");
        String valueKey = firstText(props.get("value"), "id");
        String childrenKey = firstText(props.get("children"), "children");
        Map<String, String> result = new LinkedHashMap<>();
        collectOptionLabels(options, labelKey, valueKey, childrenKey, result);
        return result;
    }
 
    @SuppressWarnings("rawtypes")
    private static void collectOptionLabels(List<?> options, String labelKey, String valueKey,
                                            String childrenKey, Map<String, String> result) {
        if (options == null) {
            return;
        }
        for (Object raw : options) {
            if (!(raw instanceof Map)) {
                continue;
            }
            Map option = (Map) raw;
            String value = firstText(option.get(valueKey), option.get("enCode"),
                    option.get("id"), option.get("value"));
            String label = firstText(option.get(labelKey), option.get("fullName"),
                    option.get("label"), option.get("name"));
            if (value != null && label != null) {
                result.put(value, label);
            }
            Object children = option.get(childrenKey);
            if (children instanceof List) {
                collectOptionLabels((List<?>) children, labelKey, valueKey, childrenKey, result);
            }
        }
    }
 
    private static String componentType(String jnpfKey) {
        if (jnpfKey == null) {
            return null;
        }
        return "signature".equalsIgnoreCase(jnpfKey) ? "sign" : jnpfKey;
    }
 
    private static String valueType(String jnpfKey, String dataType,
                                    Map<String, String> options, Boolean multiple) {
        String key = lower(jnpfKey);
        String source = lower(dataType);
        if (key == null) {
            return null;
        }
        if ("sign".equals(key) || "signature".equals(key)) {
            return "masked";
        }
        if ("datepicker".equals(key)) {
            return "date";
        }
        if ("timepicker".equals(key)) {
            return "datetime";
        }
        if ("inputnumber".equals(key) || "slider".equals(key) || "rate".equals(key)) {
            return "number";
        }
        if ("switch".equals(key)) {
            return "boolean";
        }
        if ("table".equals(key) || "uploadfile".equals(key) || "uploadimg".equals(key)) {
            return "json";
        }
        if ("userselect".equals(key) || "departmentselect".equals(key)
                || "roleselect".equals(key) || "groupselect".equals(key)
                || "popupselect".equals(key) || "relationform".equals(key)) {
            return Boolean.TRUE.equals(multiple) ? "multiReference" : "reference";
        }
        if ("checkbox".equals(key) || "cascader".equals(key) || "treeselect".equals(key)) {
            return ("dynamic".equals(source) && (options == null || options.isEmpty()))
                    ? "multiReference" : "multiEnum";
        }
        if ("select".equals(key) || "radio".equals(key)) {
            if ("dynamic".equals(source) && (options == null || options.isEmpty())) {
                return Boolean.TRUE.equals(multiple) ? "multiReference" : "reference";
            }
            return Boolean.TRUE.equals(multiple) ? "multiEnum" : "enum";
        }
        return "text";
    }
 
    private void clearMetadataCaches() {
        modelIdToMainTable.clear();
        modelIdToFieldMeta.clear();
        modelIdToTitleSpec.clear();
        modelIdToListTitleSpec.clear();
        modelIdToSignFields.clear();
        tableToCommentMeta.clear();
    }
 
    private <T> T cachedValue(Map<String, CacheValue<T>> cache, String key) {
        CacheValue<T> cached = cache.get(key);
        if (cached == null) {
            return null;
        }
        if (metadataCacheTtlMs <= 0L
                || System.currentTimeMillis() - cached.createdAt >= metadataCacheTtlMs) {
            cache.remove(key, cached);
            return null;
        }
        return cached.value;
    }
 
    private <T> void cacheValue(Map<String, CacheValue<T>> cache, String key, T value) {
        if (metadataCacheTtlMs > 0L) {
            cache.put(key, new CacheValue<>(value));
        }
    }
 
    private static final class CacheValue<T> {
        private final T value;
        private final long createdAt = System.currentTimeMillis();
 
        private CacheValue(T value) {
            this.value = value;
        }
    }
 
    /** 解析 modelId 对应的主表名(小写);反查不到返回 null。 */
    public String resolveMainTable(String modelId) {
        if (modelId == null || modelId.isEmpty()) {
            return null;
        }
        // ConcurrentHashMap 存不了 null,用空串当「确定性地查不到」的占位(L061 ① 的哨兵写法)
        String cached = cachedValue(modelIdToMainTable, modelId);
        if (cached != null) {
            return cached.isEmpty() ? null : cached;
        }
        Outcome outcome = queryMainTable(modelId);
        if (outcome.transientFailure) {
            // 瞬时失败(连接抖动/超时)**不进缓存**:下次事件重试一次,代价只是多一次查询;
            // 缓存了则是该表单从此静默不被审计,两者的坏处不在一个量级。
            log.error("[AUDIT-L0] 反查主表失败(瞬时,不缓存,下次事件会重试)modelId={} err={}",
                    modelId, outcome.error);
            return null;
        }
        cacheValue(modelIdToMainTable, modelId, outcome.table == null ? "" : outcome.table);
        if (outcome.table == null && warnedModels.add(modelId)) {
            log.warn("[AUDIT-L0] modelId={} 反查不到主表(base_visual_release.F_TABLES_DATA 无 typeId=1 且无表项),"
                    + "该表单的变更不会被审计。同一 modelId 只报一次", modelId);
        }
        return outcome.table;
    }
 
    /** 查询结果三态:拿到表名 / 确定性地没有 / 瞬时失败(后者不可缓存)。 */
    private static final class Outcome {
        private String table;
        private boolean transientFailure;
        private String error;
    }
 
    @SuppressWarnings({"rawtypes", "unchecked"})
    private Outcome queryMainTable(String modelId) {
        Outcome outcome = new Outcome();
        String tablesJson;
        try {
            tablesJson = jdbcTemplate.queryForObject(
                    "SELECT F_TABLES_DATA FROM base_visual_release WHERE F_ID = ?",
                    String.class, modelId);
        } catch (org.springframework.dao.EmptyResultDataAccessException e) {
            // 确定性:这个 modelId 根本没有发布记录,再查一万次也一样
            return outcome;
        } catch (Throwable t) {
            outcome.transientFailure = true;
            outcome.error = String.valueOf(t);
            return outcome;
        }
        // 以下都是「查询成功、只是内容里没有主表」——确定性结果,可以缓存
        if (tablesJson == null || tablesJson.isEmpty()) {
            return outcome;
        }
        // **解析单独一个 try**:下面 deterministicParseFailure 的白名单之所以敢认
        // NumberFormatException,前提正是「这个 try 里除了解析没有别的东西」——
        // 让这个前提由**代码结构**保证,而不是靠注释约定(约定会在下一次改动里悄悄失效)。
        List<Map> tables;
        try {
            tables = JsonUtil.getJsonToList(tablesJson, Map.class);
        } catch (Throwable t) {
            // **白名单,不是黑名单**(L061 ①,独立评审 I-3 指出这里退化成了黑名单):
            // 只认解析器对这段文本的固有判决;OOM / StackOverflowError 这类**瞬时**故障
            // 若被判成确定性并永久负缓存,等于该表单从此静默不被审计直到重启。
            if (deterministicParseFailure(t)) {
                log.warn("[AUDIT-L0] modelId={} 的 F_TABLES_DATA 不是合法 JSON(确定性失败,已缓存): {}",
                        modelId, t.toString());
                return outcome;
            }
            outcome.transientFailure = true;
            outcome.error = String.valueOf(t);
            return outcome;
        }
        if (tables == null || tables.isEmpty()) {
            return outcome;
        }
        // 以下是纯 Map 取值,与解析无关。这里出的异常(元素不是 Map 等)同样是这条发布记录的
        // 固有形态、重试结果恒定,故一律按确定性处理。
        try {
            for (Map t : tables) {
                if ("1".equals(String.valueOf(t.get("typeId")))) {   // typeId=1 是主表
                    outcome.table = lower(str(t.get("table")));
                    if (outcome.table != null) {
                        return outcome;
                    }
                }
            }
            // 没有 typeId=1 的标记(老表单结构里出现过):退而取第一张表,与旧 BizFormRegistry 同口径
            outcome.table = lower(str(tables.get(0).get("table")));
            return outcome;
        } catch (Throwable t) {
            log.warn("[AUDIT-L0] modelId={} 的 F_TABLES_DATA 结构异常(确定性失败,已缓存): {}",
                    modelId, t.toString());
            outcome.table = null;
            return outcome;
        }
    }
 
    /**
     * 解析失败是否为「这段文本的固有判决」(重试结果恒定),只有这种才允许进负缓存。
     *
     * <p>白名单两项,都经实测确认(第二项由 codex 独立复核时用 fastjson 1.2.83 字节码级探针发现):
     * <ul>
     *   <li>{@link com.alibaba.fastjson.JSONException}——语法错、顶层非数组、元素非对象、
     *       超大指数等,fastjson 统一包成它;</li>
     *   <li>{@link NumberFormatException}——**畸形 Unicode 转义(如 {@code \\uZZZZ})与畸形数字
     *       字面量会抛裸的 NFE、不被包装**。它同样是文本的固有属性,漏在白名单外只会让这类
     *       表单每次事件都白查一次库(不影响正确性,但属实打实的效率死角)。</li>
     * </ul>
     *
     * <p><b>刻意不收 Error</b>:{@code StackOverflowError}(深层嵌套 JSON)实测**原样穿透、
     * 不被包装成 JSONException</b>,落进瞬时分支不缓存——正是想要的行为。
     */
    private static boolean deterministicParseFailure(Throwable t) {
        for (Throwable c = t; c != null; c = (c.getCause() == c ? null : c.getCause())) {
            if (c instanceof NumberFormatException) {
                return true;
            }
            if (c instanceof com.alibaba.fastjson.JSONException) {
                return true;
            }
        }
        return false;
    }
 
    private static String str(Object value) {
        if (value == null) {
            return null;
        }
        String s = value.toString().trim();
        return s.isEmpty() ? null : s;
    }
 
    private static Boolean bool(Object value) {
        if (value instanceof Boolean) {
            return (Boolean) value;
        }
        if (value == null) {
            return null;
        }
        String text = String.valueOf(value).trim();
        if ("true".equalsIgnoreCase(text)) {
            return Boolean.TRUE;
        }
        if ("false".equalsIgnoreCase(text)) {
            return Boolean.FALSE;
        }
        return null;
    }
 
    private static String firstText(Object... values) {
        if (values == null) {
            return null;
        }
        for (Object value : values) {
            String text = str(value);
            if (text != null) {
                return text;
            }
        }
        return null;
    }
 
    private static String lower(String value) {
        return value == null ? null : value.toLowerCase(Locale.ROOT);
    }
 
    private static Set<String> lowerAll(Set<String> values) {
        if (values == null || values.isEmpty()) {
            return Collections.emptySet();
        }
        Set<String> out = new LinkedHashSet<>(values.size());
        for (String v : values) {
            if (v != null && !v.trim().isEmpty()) {
                out.add(lower(v.trim()));
            }
        }
        return out;
    }
}