package jnpf.bizcommon.audit.service.sign; import jnpf.audit.diff.AuditFieldDiff; import jnpf.bizcommon.audit.service.listener.AuditVisualLogListener; import jnpf.bizcommon.audit.service.title.AuditTitleSpec; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; /** * 读取电子签名凭证事实。 * *

为什么硬编码 {@code lims_sign} 而不做成配置:签名将整体挪进通用服务 * (规划中的 {@code jnpf-biz-common} 之类),配置化是为一个已计划废弃的中间态买单。 * 所以把耦合收在本类一处:对外只有 {@link #read},届时换本类实现即可,调用方零改动。 * *

批量读,不逐个查:与 {@code AuditFieldValueResolver} 的 SPI 约定同口径—— * 层 0 跑在 MQ 消费线程上,逐 ID 查库会把消费速率拖垮。 */ @Slf4j @Component public class AuditSignEvidenceReader { /** * 签名凭证表。挪到通用服务时改这里(连同下面的列名)。 * *

public 是刻意的:签名事件的 {@code target_table} 写的是它,而 A1-b 的历史反查 * ({@code selectRecordedSignIds})要按同一个值去查——两处分别写一份字面量,改表名时 * 漏掉一处就会让反查恒查不到、历史签名重新被当成本次签的,而且不报任何错。 */ public static final String SIGN_TABLE = "lims_sign"; /** 单次 IN 的上限。一份表单上的签名字段个位数,超了说明判定跑偏,宁可少查也不打爆库。 */ private static final int MAX_IDS = 20; @Autowired private JdbcTemplate jdbcTemplate; /** * 本次事件里「可能是签名字段」的候选(小写、去重、稳定顺序)。 * *

三条来源,按优先级排列——{@code ordered} 是 {@link LinkedHashSet}(保序), * {@link #MAX_IDS} 顶格截断时先保住排在前面的: *

    *
  1. {@code configuredFields}(D4 权威来源,见 * {@link jnpf.bizcommon.audit.service.AuditFormRegistry#resolveSignFields}):表单配置 * {@code confirmBtnConfig}/{@code reviewBtnConfig} 里显式指定的 {@code biz_sign_field}。 * 多人签名场景下字段名可以是任意的,必须最先放进候选集,否则会被后两条来源 * 挤到 20 上限之外;
  2. *
  3. 命名规则命中 {@code isCredentialReference} 的字段——恒为候选,不需要勾选, * 这批字段本就已被强制脱敏(命名规则+撞库兜底,覆盖配置没提到的字段);
  4. *
  5. 表单上勾了 {@code biz_log_enabled} 的主表字段——是不是签名由 {@link #read} * 查表定,本方法只负责挑出"值得去查一下"的那些。
  6. *
* *

值为空的一律不进候选:空值查签名表纯属浪费,还会把空串塞进 IN 参数。 */ public static List candidateFields(Map row, AuditTitleSpec spec, List configuredFields) { if (row == null || row.isEmpty()) { return Collections.emptyList(); } Set ordered = new LinkedHashSet(); if (configuredFields != null) { for (String field : configuredFields) { if (field == null) { continue; } String lowerField = field.toLowerCase(Locale.ROOT); if (hasValue(valueOf(row, lowerField))) { ordered.add(lowerField); } } } for (Map.Entry entry : row.entrySet()) { String field = entry.getKey(); if (field != null && AuditVisualLogListener.isCredentialReference(field) && hasValue(entry.getValue())) { ordered.add(field.toLowerCase(Locale.ROOT)); } } if (spec != null) { for (String field : spec.mainFields()) { if (field == null) { continue; } String lower = field.toLowerCase(Locale.ROOT); if (hasValue(valueOf(row, lower))) { ordered.add(lower); } } } return ordered.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList(ordered)); } /** * 按签名 id 批量读取签名事实。查不到的 id 不出现在结果里(= 那个字段不是签名字段)。 * *

任何异常都返回空 Map 并告警:签名事实读不到时,宁可这次不产出签名事件, * 也不能因此丢掉业务事件——审计的红线是「这次改过不能缺」。 * *

🔴 按租户收窄(2026-08-05 批 D):{@code lims_sign} 主键是 * {@code (f_id, f_tenant_id)},{@code f_id} 单列唯一不是数据库不变量——「雪花 id * 全局唯一」是实现约定不是约束。批 A 已经在历史反查侧({@code selectRecordedSignIds}) * 钉过这一条,但读取侧这一半当时漏了:不收窄的话,请求里塞一个别的租户的 signId * 照样能读出那一行,然后被当成本次操作的签名凭证写进 append-only 的审计表。 * 实查当前库 1306 条签名全在租户 '0'、0 个跨租户重复的 f_id——是埋着的陷阱不是活的故障, * 但两侧口径必须一致,否则下一个人只会看到「反查按租户、读取不按」而无从判断哪个是对的。 * * @param tenantId 调用方的租户({@code AuditCtx.tenantId},永不为 null,见其默认值) */ public Map read(Collection signIds, String tenantId) { if (signIds == null || signIds.isEmpty()) { return Collections.emptyMap(); } List ids = new ArrayList(); for (String id : signIds) { if (id != null && !id.trim().isEmpty() && ids.size() < MAX_IDS) { ids.add(id.trim()); } } if (ids.isEmpty()) { return Collections.emptyMap(); } StringBuilder sql = new StringBuilder( "SELECT f_id, account_name, f_creator_user_id, note, op_type, f_creator_time, meta_data FROM ") .append(SIGN_TABLE).append(" WHERE f_id IN ("); for (int i = 0; i < ids.size(); i++) { sql.append(i == 0 ? "?" : ",?"); } sql.append(") AND f_tenant_id = ?"); List args = new ArrayList(ids); args.add(tenantId); try { List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); Map out = new LinkedHashMap(); for (Map r : rows) { String id = str(r.get("f_id")); if (id == null) { continue; } out.put(id, new AuditSignEvidence(id, str(r.get("account_name")), str(r.get("f_creator_user_id")), str(r.get("note")), str(r.get("op_type")), date(r.get("f_creator_time")), str(r.get("meta_data")))); } return out; } catch (Throwable t) { log.error("[AUDIT-L0] 读取签名凭证失败,本次不产出签名事件(业务事件不受影响)ids={}", ids.size(), t); return Collections.emptyMap(); } } private static Object valueOf(Map row, String lowerField) { for (Map.Entry entry : row.entrySet()) { if (entry.getKey() != null && lowerField.equals(entry.getKey().toLowerCase(Locale.ROOT))) { return entry.getValue(); } } return null; } private static boolean hasValue(Object value) { return !AuditFieldDiff.stringify(value).trim().isEmpty(); } private static String str(Object value) { if (value == null) { return null; } String s = value.toString().trim(); return s.isEmpty() ? null : s; } private static Date date(Object value) { if (value instanceof Timestamp) { return new Date(((Timestamp) value).getTime()); } return value instanceof Date ? (Date) value : null; } }