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<表名, 元数据></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;
|
}
|
}
|