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 时按本方法把这些字段显式补成空值即可闭合。
*
*
为什么是「已发布表单字段 ∩ 主表物理列」这个交集,而不是任一单侧:
*
* - 只用物理列 → 会把**后端代码写、不经表单**的列(如 {@code jieshouren})也宣称为
* 「建单时已知为空」。那些列的变更不产生层 0 事件,baseline 会长期停在假的空值上,
* 下次表单碰到它就编出一条 {@code ""→X} 的假 diff——正是 B3 划的「没有 before
* 不许给 field_diffs」那条红线。
* - 只用表单字段 → 会带进子表 children 的 vModel({@link #collectFields} 是递归的),
* 那些不是主表的列,补进主表快照只是噪音。
*
* 取交集后剩下的正是「这张表单控制、且确实是主表一列」的字段——建单提交的就是整份表单,
* 没被携带即等于留空,这个推断有据可依。
*
* 任一侧取不到就返回空集(非 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} 完全相同,不能松:
*
* - 表名必须在层 0 注册表白名单内——否则元数据 SQL 会被任意表名驱动;
* - modelId 反查出的主表必须与 {@code table} 一致——查询历史事件时
* {@code sourceModelId} 来自事件 extra,不能假定它属于当前表;
* 用另一张表的发布模型解析标题,会把别的表的字段名当成本表的勾选。
*
* 任一条不满足返回 {@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