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):回答「这个在线表单要不要审计、算哪个业务模块」。 * *

取代 lims 侧 {@code BizFormRegistry} 的 18 条硬编码静态 Map——那张表每接一个新表单就要改 * lims 的代码、重新打包、重启服务,业务模块之间还会互相踩。现在改成 Nacos 配置驱动 * (data-id {@code audit-layer0-forms.yaml}),各业务模块自助注册自己的表单,audit 服务不动代码。 * *

按主表名注册,而不是按 modelId:modelId 是无意义的雪花数字({@code 844231923957086149}), * 写进配置没人看得懂也没人敢改;而且同一张业务主表常有多个表单视图(列表页/详情页/移动端各一个 * modelId),按表登记只需一条。modelId → 主表名这一跳仍走 JDBC 反查 {@code base_visual_release} * 并缓存,与旧实现同口径——A/B 比对时少一个变量。 * *

是否只按清单审计由 {@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 allowedAppCodes = new ArrayList<>(); private volatile Set allowedAppCodeSet = Collections.emptySet(); /** true 时,应用前台访问的未显式登记在线表单也纳入审计。 */ private volatile boolean includeUnregisteredForms = false; /** * 注册清单,由 Nacos 配置注入。 * *

形态必须是 List 而不是 Map<表名, 元数据>(C1 实测踩到):Spring Boot Binder * 对 Map 属性做的是合并——刷新时它先调 getter 拿到现有 Map,再把新配置 put 进去, * 于是被删掉的表单永远留在注册表里,「从清单移除一张表」只有重启才生效。 * 审计范围只能加不能减,是运营上的真隐患(实测:撤回配置后那张表照记不误)。 * 索引集合(List)的绑定则是整体替换,setter 每次都拿到完整的新清单,增删都即时生效。 */ private List forms = new ArrayList<>(); /** 主表名(小写)→ 元数据。由 {@link #setForms} 从清单重建,每次刷新整体替换。 */ private volatile Map formsByTable = Collections.emptyMap(); /** * modelId → 主表名 的运行时缓存。 * *

只缓存确定性结果(空串占位表示「确定地查不到」):查得到发布记录但里面没有表、 * 或那段 JSON 根本不合法——重试一万次也一样。**瞬时故障一律不缓存**(连接抖动、超时、 * OOM/SO)——L061 ①:缓存"失败"必须区分确定性与瞬时,而且必须用白名单判定。 * 这里的缓存是进程级、无过期的,一次连接抖动若被记成「这个 modelId 没有主表」, * 该表单从此到重启前**永远不被审计**,而且日志里什么都没有。 * *

缓存使用有限 TTL;注册表热刷新和 TTL 配置变化还会主动清空。表单重新发布但未触发 * 注册表刷新时,最迟在 TTL 到期后重新读取,避免历史查询长期绑定旧配置。 */ private final Map> modelIdToMainTable = new ConcurrentHashMap<>(); /** modelId → 已发布表单字段元数据;只缓存确定性查询/解析结果。 */ private final Map>> modelIdToFieldMeta = new ConcurrentHashMap<>(); /** modelId → 标题勾选配置;与字段元数据同一套 TTL / 只缓存确定性结果的口径。 */ private final Map> modelIdToTitleSpec = new ConcurrentHashMap<>(); /** 列表标题规格缓存(D6)。与 modelIdToTitleSpec 分开:同一 modelId 两种口径不能共用一格。 */ private final Map> modelIdToListTitleSpec = new ConcurrentHashMap<>(); /** 签名字段配置缓存(D4)。 */ private final Map>> modelIdToSignFields = new ConcurrentHashMap<>(); /** 数据库字段备注缓存;查询异常时不写缓存。 */ private final Map>> tableToCommentMeta = new ConcurrentHashMap<>(); /** 发布表单、主表反查及 COMMENT 元数据缓存 TTL;Nacos 刷新注册表时还会主动清空。 */ private volatile long metadataCacheTtlMs = 300_000L; /** 反查不到主表的 modelId 只告警一次,避免高频表单把日志刷爆。 */ private final Set 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 maskedFields = Collections.emptySet(); } /** * 启动完成后打印一次**权威**生效态。 * *

{@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 getAllowedAppCodes() { return allowedAppCodes; } public void setAllowedAppCodes(List allowedAppCodes) { this.allowedAppCodes = allowedAppCodes == null ? new ArrayList<>() : allowedAppCodes; Set 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 getForms() { return forms; } /** * 从清单重建查找表,**整体替换**(这正是用 List 的意义,见 {@link #forms} 注释)。 * *

表名与脱敏字段一律归一成小写:PG 里的表名是小写,而配置是手写的(大小写、空格随人), * 在入口处统一一次,比在每个查询点各写一次 toLowerCase 可靠——漏一处就是脱敏静默失效。 */ public void setForms(List forms) { this.forms = forms == null ? new ArrayList<>() : forms; Map 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 resolveFieldMetadata(String modelId, String table) { String normalizedTable = table == null ? null : lower(table.trim()); if (normalizedTable == null || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable))) { return Collections.emptyMap(); } Map comments = commentMetadata(normalizedTable); if (modelId == null || modelId.trim().isEmpty()) { return comments; } // 查询历史事件时 sourceModelId 来自事件 extra,不能假定它一定属于 targetTable。 // 不匹配时只保留当前表 COMMENT,避免用另一张表的发布元数据错误装饰字段名。 if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) { return comments; } Map published = publishedFieldMetadata(modelId); return mergeFieldMetadata(comments, published); } /** * CREATE 建立**完整** baseline 用的「已知可审计字段」集合(小写)。 * *

为什么需要这个:平台把值为空的字段整个从事件的 {@code newData} 里剥掉了 * (2026-07-30 实测:{@code lims_qingyandan} 的 CREATE 快照 21 键 vs 表单模型 23 字段, * 少的正是建单时留空的 {@code youxiaoqi} / {@code piliang} / {@code piliang_danwei})。 * 于是 baseline 天生缺这些键,用户第一次把它们从空填成非空时,UPDATE 的两侧交集会把它们 * 排除掉——**真实变更被漏记**。CREATE 时按本方法把这些字段显式补成空值即可闭合。 * *

为什么是「已发布表单字段 ∩ 主表物理列」这个交集,而不是任一单侧: *

* 取交集后剩下的正是「这张表单控制、且确实是主表一列」的字段——建单提交的就是整份表单, * 没被携带即等于留空,这个推断有据可依。 * *

任一侧取不到就返回空集(非 PG 部署、元数据查询瞬时失败、modelId 与 table 对不上): * 不补 = 退化成今天的「baseline 缺字段 → 显式标记不可判定」,是安全方向。 * 宁可继续少记一条,也不能凭猜造出一个旧值。 */ public Set 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 published = publishedFieldMetadata(modelId.trim()); Map columns = commentMetadata(normalizedTable); if (published.isEmpty() || columns.isEmpty()) { return Collections.emptySet(); } Set 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 resolvePhysicalColumns(String table) { String normalized = table == null ? null : lower(table.trim()); if (normalized == null || !normalized.matches("[a-z0-9_]+")) { return Collections.emptySet(); } Map columns = commentMetadata(normalized); return columns.isEmpty() ? Collections.emptySet() : Collections.unmodifiableSet(new LinkedHashSet<>(columns.keySet())); } /** * 取当前已发布表单的「操作记录标题」勾选配置。 * *

两道闸与 {@link #resolveFieldMetadata} 完全相同,不能松: *

* 任一条不满足返回 {@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}。 * *

与 {@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 权威来源)。 * *

{@code confirmBtnConfig.biz_sign_field}(确定按钮)与 * {@code reviewBtnConfig.biz_sign_field}(复核按钮)。多人签名场景下字段名可以是任意的, * 光靠 {@code _sign} 命名规则会漏——这就是为什么必须读配置而不是只靠猜。 * 实测「测试表单」两者分别是 {@code biz_sign} 与 {@code fuhe_sign}。 * *

返回小写字段名,与 {@link jnpf.bizcommon.audit.service.sign.AuditSignEvidenceReader#candidateFields} * 的口径一致。解析不出返回空列表(调用方自会退回命名规则+撞库兜底),不抛异常。 */ public List 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 publishedSignFields(String modelId) { List 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.emptyList()); return Collections.emptyList(); } catch (Throwable t) { // 瞬时故障不缓存(L061 ①) log.error("[AUDIT-L0] 读取签名字段配置失败(不缓存)modelId={}", modelId, t); return Collections.emptyList(); } List fields = parseSignFields(formData); cacheValue(modelIdToSignFields, modelId, fields); return fields; } /** 从表单根对象读两个按钮配置的 biz_sign_field。永不抛异常,解析不出返回空表。 */ private static List parseSignFields(String formDataJson) { if (formDataJson == null || formDataJson.trim().isEmpty()) { return Collections.emptyList(); } try { Map root = JsonUtil.stringToMap(formDataJson); if (root == null) { return Collections.emptyList(); } List 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.emptyList() : Collections.unmodifiableList(out); } catch (Throwable t) { return Collections.emptyList(); } } /** preferred 按字段名(忽略大小写)覆盖 fallback。 */ public static Map mergeFieldMetadata( Map fallback, Map preferred) { if ((fallback == null || fallback.isEmpty()) && (preferred == null || preferred.isEmpty())) { return Collections.emptyMap(); } Map merged = new LinkedHashMap<>(); putAllNormalized(merged, fallback); if (preferred != null) { for (Map.Entry 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 publishedFieldMetadata(String modelId) { if (modelId == null || modelId.trim().isEmpty()) { return Collections.emptyMap(); } String key = modelId.trim(); Map 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 parsed = parsePublishedFieldMetadata(formData); Map 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 commentMetadata(String table) { Map cached = cachedValue(tableToCommentMeta, table); if (cached != null) { return cached; } try (Connection connection = jdbcTemplate.getDataSource().getConnection()) { Map 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 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 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 parsePublishedFieldMetadata(String formDataJson) { if (formDataJson == null || formDataJson.trim().isEmpty()) { return Collections.emptyMap(); } Map root = JsonUtil.stringToMap(formDataJson); if (root == null) { return Collections.emptyMap(); } Object rawFields = root.get("fields"); List fields; if (rawFields instanceof List) { fields = (List) rawFields; } else if (rawFields != null) { fields = JsonUtil.getJsonToList(String.valueOf(rawFields), Map.class); } else { fields = Collections.emptyList(); } Map result = new LinkedHashMap<>(); collectFields(fields, result); return result; } @SuppressWarnings({"rawtypes", "unchecked"}) private static void collectFields(List fields, Map 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 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 target, Map source) { if (source == null) { return; } for (Map.Entry entry : source.entrySet()) { putNormalized(target, entry.getKey(), entry.getValue()); } } private static void putNormalized(Map 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 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 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 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 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 cachedValue(Map> cache, String key) { CacheValue 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 void cacheValue(Map> cache, String key, T value) { if (metadataCacheTtlMs > 0L) { cache.put(key, new CacheValue<>(value)); } } private static final class CacheValue { 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 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; } } /** * 解析失败是否为「这段文本的固有判决」(重试结果恒定),只有这种才允许进负缓存。 * *

白名单两项,都经实测确认(第二项由 codex 独立复核时用 fastjson 1.2.83 字节码级探针发现): *

* *

刻意不收 Error:{@code StackOverflowError}(深层嵌套 JSON)实测**原样穿透、 * 不被包装成 JSONException,落进瞬时分支不缓存——正是想要的行为。 */ 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 lowerAll(Set values) { if (values == null || values.isEmpty()) { return Collections.emptySet(); } Set out = new LinkedHashSet<>(values.size()); for (String v : values) { if (v != null && !v.trim().isEmpty()) { out.add(lower(v.trim())); } } return out; } }