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;
|
|
/**
|
* 读取电子签名凭证事实。
|
*
|
* <p><b>为什么硬编码 {@code lims_sign} 而不做成配置</b>:签名将整体挪进通用服务
|
* (规划中的 {@code jnpf-biz-common} 之类),配置化是为一个已计划废弃的中间态买单。
|
* 所以把耦合<b>收在本类一处</b>:对外只有 {@link #read},届时换本类实现即可,调用方零改动。
|
*
|
* <p><b>批量读,不逐个查</b>:与 {@code AuditFieldValueResolver} 的 SPI 约定同口径——
|
* 层 0 跑在 MQ 消费线程上,逐 ID 查库会把消费速率拖垮。
|
*/
|
@Slf4j
|
@Component
|
public class AuditSignEvidenceReader {
|
|
/**
|
* 签名凭证表。挪到通用服务时改这里(连同下面的列名)。
|
*
|
* <p><b>public 是刻意的</b>:签名事件的 {@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;
|
|
/**
|
* 本次事件里「可能是签名字段」的候选(小写、去重、稳定顺序)。
|
*
|
* <p>三条来源,按优先级排列——{@code ordered} 是 {@link LinkedHashSet}(保序),
|
* {@link #MAX_IDS} 顶格截断时先保住排在前面的:
|
* <ol>
|
* <li>{@code configuredFields}(D4 权威来源,见
|
* {@link jnpf.bizcommon.audit.service.AuditFormRegistry#resolveSignFields}):表单配置
|
* {@code confirmBtnConfig}/{@code reviewBtnConfig} 里显式指定的 {@code biz_sign_field}。
|
* 多人签名场景下字段名可以是任意的,必须<b>最先</b>放进候选集,否则会被后两条来源
|
* 挤到 20 上限之外;</li>
|
* <li>命名规则命中 {@code isCredentialReference} 的字段——恒为候选,不需要勾选,
|
* 这批字段本就已被强制脱敏(命名规则+撞库兜底,覆盖配置没提到的字段);</li>
|
* <li>表单上勾了 {@code biz_log_enabled} 的主表字段——是不是签名由 {@link #read}
|
* 查表定,本方法只负责挑出"值得去查一下"的那些。</li>
|
* </ol>
|
*
|
* <p><b>值为空的一律不进候选</b>:空值查签名表纯属浪费,还会把空串塞进 IN 参数。
|
*/
|
public static List<String> candidateFields(Map<String, Object> row, AuditTitleSpec spec,
|
List<String> configuredFields) {
|
if (row == null || row.isEmpty()) {
|
return Collections.emptyList();
|
}
|
Set<String> ordered = new LinkedHashSet<String>();
|
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<String, Object> 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.<String>emptyList()
|
: Collections.unmodifiableList(new ArrayList<String>(ordered));
|
}
|
|
/**
|
* 按签名 id 批量读取签名事实。查不到的 id 不出现在结果里(= 那个字段不是签名字段)。
|
*
|
* <p>任何异常都返回空 Map 并告警:签名事实读不到时,宁可这次不产出签名事件,
|
* 也不能因此丢掉业务事件——审计的红线是「这次改过不能缺」。
|
*
|
* <p>🔴 <b>按租户收窄</b>(2026-08-05 批 D):{@code lims_sign} 主键是
|
* {@code (f_id, f_tenant_id)},<b>{@code f_id} 单列唯一不是数据库不变量</b>——「雪花 id
|
* 全局唯一」是实现约定不是约束。批 A 已经在历史反查侧({@code selectRecordedSignIds})
|
* 钉过这一条,但<b>读取侧这一半当时漏了</b>:不收窄的话,请求里塞一个别的租户的 signId
|
* 照样能读出那一行,然后被当成本次操作的签名凭证写进 append-only 的审计表。
|
* 实查当前库 1306 条签名全在租户 '0'、0 个跨租户重复的 f_id——是埋着的陷阱不是活的故障,
|
* 但两侧口径必须一致,否则下一个人只会看到「反查按租户、读取不按」而无从判断哪个是对的。
|
*
|
* @param tenantId 调用方的租户({@code AuditCtx.tenantId},永不为 null,见其默认值)
|
*/
|
public Map<String, AuditSignEvidence> read(Collection<String> signIds, String tenantId) {
|
if (signIds == null || signIds.isEmpty()) {
|
return Collections.emptyMap();
|
}
|
List<String> ids = new ArrayList<String>();
|
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<Object> args = new ArrayList<Object>(ids);
|
args.add(tenantId);
|
try {
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
Map<String, AuditSignEvidence> out = new LinkedHashMap<String, AuditSignEvidence>();
|
for (Map<String, Object> 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<String, Object> row, String lowerField) {
|
for (Map.Entry<String, Object> 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;
|
}
|
}
|