刘光辉
15 小时以前 34981c30a78e8bbd7791131059a9210f9928b62c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
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;
    }
}