刘光辉
昨天 bb638871a7fb692d80f1b7a758f991dc0879002c
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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
package jnpf.bizcommon.audit.service.title;
 
import jnpf.bizcommon.audit.entity.AuditEventEntity;
import jnpf.audit.diff.AuditFieldDiff;
import jnpf.bizcommon.audit.entity.model.AuditFieldDiffViewVO;
import jnpf.bizcommon.audit.service.AuditFieldDiffViewService;
import jnpf.util.JsonUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
 
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
 
/**
 * 把表单勾选渲染成人可读的「操作记录标题」。
 *
 * <p><b>用展示值不用存储值</b>:字典/引用/枚举字段的存储值是雪花 ID 或字典码,直接拼进标题
 * 就是一串数字。这里复用 {@link AuditFieldDiffViewService#build}——也就是 {@code field_diffs}
 * 走的<b>同一条</b>展示解析路径,保证标题与字段明细里的同一个字段显示成同一个值。
 *
 * <p><b>三类字段绝不进标题</b>(顺序即优先级):
 * <ol>
 *   <li>调用方传入的 {@code excludedFields}——脱敏列与判定为签名字段的列。
 *       标题是列表明文列,凭证/敏感值进去就洗不掉(审计表 append-only 保留 10 年);</li>
 *   <li>展示解析后仍被标记 {@code masked} 的——双保险,防调用方漏传;</li>
 *   <li>值为空的——跳过而不是留一个空的 {@code /} 段。</li>
 * </ol>
 */
@Slf4j
@Component
public class AuditRecordTitleBuilder {
 
    /** 字段间分隔符。业务口径:a/b/c。 */
    public static final String SEPARATOR = "/";
 
    /** 单行标题的字符上限。主表行落 varchar(500),留出余量;子表行同口径避免 extra 无限膨胀。 */
    private static final int MAX_LINE = 400;
 
    /**
     * 子表**标题条数**上限(产出侧)。超出的丢弃——列表只显示前 3 行,全量给详情。
     * 2026-08-05 起丢弃这件事会如实留痕({@link AuditRecordTitle#SCOPE_ROWS_TRUNCATED}),
     * 此前是悄悄截的:读的人看到 200 条会以为那就是全部。
     */
    private static final int MAX_SUB_ROWS = 200;
 
    /**
     * 子表**扫描行数**上限(输入侧,2026-08-03 合批后新增)。
     *
     * <p>比 {@link #MAX_SUB_ROWS} 大一档:整行标题字段为空的行渲染不出标题却仍要占一个扫描名额,
     * 两者同值时「前 200 行恰好全空」就会让 recordSubTitles 直接空掉(Codex 终审缺陷 6)。
     * 放大到 4 倍是权衡——够吸收常见的空行比例,又把合成 diff 的规模钉在常数级。
     *
     * <p>2026-08-05 起它同时是<b>物化预算</b>:{@link #subRows} 按剩余额度截断复制,
     * 外层名额用尽直接 break 掉剩余子表(Codex 复审 Medium④)。此前它只挡渲染、挡不住复制。
     */
    private static final int MAX_SUB_SCAN = MAX_SUB_ROWS * 4;
 
    @Autowired
    private AuditFieldDiffViewService fieldDiffViewService;
 
    public AuditRecordTitle build(AuditTitleSpec spec,
                                  Map<String, Object> row,
                                  AuditEventEntity valueContext,
                                  Map<String, AuditFieldDiff.FieldMeta> labels,
                                  Set<String> excludedFields) {
        return build(spec, row, valueContext, labels, excludedFields,
                Collections.<String, String>emptyMap());
    }
 
    public AuditRecordTitle build(AuditTitleSpec spec,
                                  Map<String, Object> row,
                                  AuditEventEntity valueContext,
                                  Map<String, AuditFieldDiff.FieldMeta> labels,
                                  Set<String> excludedFields,
                                  Map<String, String> frontendDisplayValues) {
        if (spec == null || spec.isEmpty() || row == null || row.isEmpty()) {
            return AuditRecordTitle.EMPTY;
        }
        try {
            // ── 主表 + 全部子表行**一次解析**(2026-08-03,Codex 终审 Low:查询放大)────────
            // 原先每行各调一次 displayValues,而解析链里的 JnpfUserFieldValueResolver 每次一条
            // SQL——200 个子表行就是 201 次查询,全压在 MQ 消费线程上。解析器本身早就是批量的
            // (整批一次 IN),放大来自「按行切碎了喂给它」。这里先把所有待渲染的行攒齐,
            // 合成**一份** diff 交给同一条解析路径,再按位置分发回各行。
            List<List<String>> lineFields = new ArrayList<List<String>>();
            List<Map<String, Object>> lineRows = new ArrayList<Map<String, Object>>();
            lineFields.add(spec.mainFields());
            lineRows.add(row);                     // 下标 0 恒为主表行
 
            String subTable = null;
            boolean unavailable = false;
            // 输入侧没看全(额度耗尽)。**必须留痕**(2026-08-05 Codex 复审 Low):额度耗尽后
            // 整张子表都不碰,连「事件里没带这批数据」都测不出来,unavailable 恒为 false——
            // 「判不了」就被伪装成了「没发生」(L075)。补看的代价正是资源上界刚拆掉的那笔,
            // 所以选择如实说「我没看全」,而不是花钱看全。
            boolean scanTruncated = false;
            for (AuditTitleSpec.SubTableSpec sub : spec.subTables()) {
                if (sub.fields().isEmpty()) {
                    continue;
                }
                // 🔴 上限**下推到取行那一步**(2026-08-05 Codex 复审 Medium④):原先这道闸只挡住
                // 「进入渲染的行数」,而 subRows() 已经把整个子表反序列化并复制完了,外层达到
                // 上限之后还会继续把后面每一张子表都解析一遍——真正的资源上界从来没建立起来。
                // 现在名额用尽就 break,剩下的子表连碰都不碰;名额没用尽的传剩余额度进去,
                // 由 subRows 在物化时截断。
                int remaining = MAX_SUB_SCAN - (lineRows.size() - 1);
                if (remaining <= 0) {
                    scanTruncated = true;
                    break;
                }
                subTable = sub.tableName() != null ? sub.tableName() : sub.vModel();
                // **多要一行当哨兵**:拿回 remaining+1 行就说明这张子表还有没读的。
                // 只按 remaining 要则 rows.size()==remaining 时分不清「恰好读完」与「被截断」,
                // 而这两者对读的人是完全不同的两件事。多物化的那一行是 O(1),换来精确判定。
                List<Map<String, Object>> rows = subRows(row, sub.vModel(), remaining + 1);
                if (rows != null && rows.size() > remaining) {
                    scanTruncated = true;
                    rows = rows.subList(0, remaining);
                }
                if (rows == null) {
                    // 表单配了子表标题字段,事件里却没有这批数据。**如实留痕**——
                    // 静默当成"没有子表"会把"判不了"伪装成"本来就没有"(同 subTableUndecidable 惯例)。
                    unavailable = true;
                    continue;
                }
                for (Map<String, Object> subRow : rows) {
                    lineFields.add(sub.fields());
                    lineRows.add(subRow);
                }
            }
            // 🔴 两道上限,口径不同,缺一不可(2026-08-03 Codex 终审缺陷 6):
            //   · MAX_SUB_SCAN 限**参与解析的原始行数**——合批后行数直接决定合成 diff 的大小,
            //     没有它就得把所有行都塞进去才知道够不够,那正是要防的膨胀;
            //   · MAX_SUB_ROWS 限**产出的非空标题条数**——这是改动前的既有口径,必须保住。
            // 只留前者会退化:子表前 200 行标题字段恰好全空时,第 201 行起的有效标题全被丢掉,
            // recordSubTitles 直接空掉。所以扫描窗口开大一档,产出仍按老口径截断。
            List<String> lines = renderLines(lineFields, lineRows, valueContext, labels,
                    excludedFields, frontendDisplayValues);
            List<String> subTitles = new ArrayList<String>();
            // 产出侧截断同样留痕:这道上限从改动前就是**悄悄**截的,读的人看到 200 条
            // 会以为那就是全部(2026-08-05 Codex 复审 Low 顺带覆盖)。
            boolean rowsTruncated = false;
            // ⚠️ 先跳空行再判上限,顺序不能反:反过来的话「剩下的行恰好全渲染不出标题」
            // 也会被报成 truncated——那是把「看全了、只是没内容」说成了「没看全」,
            // 与 L075 骂的「两种缺席共用一个表示」是同一个错,只是方向相反。
            for (int i = 1; i < lines.size(); i++) {
                if (lines.get(i) == null) {
                    continue;
                }
                if (subTitles.size() >= MAX_SUB_ROWS) {
                    rowsTruncated = true;
                    break;
                }
                subTitles.add(lines.get(i));
            }
            // scanTruncated 优先:输入侧就没看全时,产出侧截没截已不是最重要的信息。
            String subScope = scanTruncated ? AuditRecordTitle.SCOPE_SCAN_TRUNCATED
                    : (rowsTruncated ? AuditRecordTitle.SCOPE_ROWS_TRUNCATED : null);
            return new AuditRecordTitle(lines.get(0), subTitles, subTable, unavailable, subScope);
        } catch (Throwable t) {
            // 标题渲染失败只该让标题缺席,不该让整条事件缺席(L002 的同款取舍,但作用域更小)
            log.warn("[AUDIT-L0] 记录标题渲染失败,本次事件不带标题:{}", t.toString());
            return AuditRecordTitle.EMPTY;
        }
    }
 
    /**
     * 表单未启用任何审计标题字段时,以当前业务记录的 {@code f_id} 作为标题。
     * 事件里的 {@code dataId} 是创建场景的可靠回退,因为提交原始数据不一定回填主键。
     */
    public String resolveMainTitle(AuditTitleSpec spec, AuditRecordTitle title,
                                   Map<String, Object> row, String dataId) {
        if (spec != null && !spec.isEmpty()) {
            return title == null ? null : title.mainTitle();
        }
        Object rowId = valueOf(row, "f_id");
        String recordId = rowId == null ? null : String.valueOf(rowId).trim();
        if (recordId == null || recordId.isEmpty()) {
            recordId = dataId == null ? null : dataId.trim();
        }
        if (recordId == null || recordId.isEmpty()) {
            return null;
        }
        return recordId.length() <= MAX_LINE ? recordId : recordId.substring(0, MAX_LINE);
    }
 
    /**
     * 多行**一次解析**:所有行的字段合成一份 diff 交给 {@link AuditFieldDiffViewService#build},
     * 再按位置切回各行。返回值与入参逐位对应,渲染不出内容的行为 {@code null}。
     *
     * <p><b>为什么按位置分发而不是按字段名</b>:字段名在跨行时会重复(每个子表行都有同一批列),
     * 按名字建 Map 会互相覆盖。{@code build} 逐项等长返回且保序——它只在 {@code field} 缺席时
     * 丢项,而这里每一项都带非空 field,所以位置是可靠的键。
     *
     * <p><b>等长前提一旦不成立就退回逐行解析</b>:多查几次只是慢,而错位会把 A 行的值显示到
     * B 行的标题上——{@code record_title} 是 append-only 存十年的明文列,写错洗不掉。
     */
    private List<String> renderLines(List<List<String>> lineFields,
                                     List<Map<String, Object>> lineRows,
                                     AuditEventEntity valueContext,
                                     Map<String, AuditFieldDiff.FieldMeta> labels,
                                     Set<String> excludedFields,
                                     Map<String, String> frontendDisplayValues) {
        List<Map<String, Object>> items = new ArrayList<Map<String, Object>>();
        List<Integer> starts = new ArrayList<Integer>();
        for (int i = 0; i < lineRows.size(); i++) {
            starts.add(items.size());
            appendItems(items, lineFields.get(i), lineRows.get(i), excludedFields);
        }
        List<String> out = new ArrayList<String>(lineRows.size());
        if (items.isEmpty()) {
            for (int i = 0; i < lineRows.size(); i++) {
                out.add(null);
            }
            return out;
        }
        List<AuditFieldDiffViewVO> views = fieldDiffViewService.build(
                valueContext, JsonUtil.getObjectToString(items), labels);
        if (views == null || views.size() != items.size()) {
            log.warn("[AUDIT-L0] 标题合批解析返回 {} 项、期望 {} 项,退回逐行解析",
                    views == null ? -1 : views.size(), items.size());
            for (int i = 0; i < lineRows.size(); i++) {
                out.add(renderLine(lineFields.get(i), lineRows.get(i), valueContext,
                        labels, excludedFields, i == 0 ? frontendDisplayValues : null));
            }
            return out;
        }
        for (int i = 0; i < lineRows.size(); i++) {
            int from = starts.get(i);
            int to = i + 1 < starts.size() ? starts.get(i + 1) : items.size();
            Map<String, String> displays = toDisplays(views.subList(from, to));
            if (i == 0) {
                mergeFrontendDisplays(displays, lineFields.get(i), excludedFields,
                        frontendDisplayValues);
            }
            out.add(displays.isEmpty() ? null : assembleLine(lineFields.get(i), displays));
        }
        return out;
    }
 
    /** 渲染一行;全部字段为空或全被排除时返回 null(不产出空行)。合批前提不成立时的退路。 */
    private String renderLine(List<String> fields, Map<String, Object> row,
                              AuditEventEntity valueContext,
                              Map<String, AuditFieldDiff.FieldMeta> labels,
                              Set<String> excludedFields,
                              Map<String, String> frontendDisplayValues) {
        Map<String, String> displays = displayValues(fields, row, valueContext, labels, excludedFields);
        mergeFrontendDisplays(displays, fields, excludedFields, frontendDisplayValues);
        if (displays.isEmpty()) {
            return null;
        }
        return assembleLine(fields, displays);
    }
 
    private void mergeFrontendDisplays(Map<String, String> displays,
                                       List<String> configuredFields,
                                       Set<String> excludedFields,
                                       Map<String, String> frontendDisplayValues) {
        if (frontendDisplayValues == null || frontendDisplayValues.isEmpty()) {
            return;
        }
        for (String field : configuredFields) {
            String normalized = field.toLowerCase(Locale.ROOT);
            if (containsIgnoreCase(excludedFields, normalized)) {
                continue;
            }
            String value = frontendDisplayValues.get(normalized);
            if (value == null || value.trim().isEmpty()) {
                continue;
            }
            displays.put(normalized, truncate(value.trim(), MAX_LINE));
        }
    }
 
    /** 把解析好的展示值按字段顺序拼成 a/b/c;全空返回 null。 */
    private String assembleLine(List<String> fields, Map<String, String> displays) {
        StringBuilder sb = new StringBuilder();
        for (String field : fields) {
            String display = displays.get(field.toLowerCase(Locale.ROOT));
            if (display == null || display.isEmpty()) {
                continue;   // 空值跳过,不产生 a//c
            }
            if (sb.length() > 0) {
                sb.append(SEPARATOR);
            }
            sb.append(display);
            if (sb.length() >= MAX_LINE) {
                return sb.substring(0, MAX_LINE);
            }
        }
        return sb.length() == 0 ? null : sb.toString();
    }
 
    /**
     * 走 {@code field_diffs} 的同一条展示解析:把待渲染字段包成一份合成 diff,
     * 交给 {@link AuditFieldDiffViewService#build} 解析出 {@code newDisplay}。
     *
     * <p>{@code "type": 0} 是 field_diffs 契约里的"新增",让解析侧只看 newData 那一侧。
     */
    private Map<String, String> displayValues(List<String> fields, Map<String, Object> row,
                                              AuditEventEntity valueContext,
                                              Map<String, AuditFieldDiff.FieldMeta> labels,
                                              Set<String> excludedFields) {
        List<Map<String, Object>> items = new ArrayList<Map<String, Object>>();
        appendItems(items, fields, row, excludedFields);
        if (items.isEmpty()) {
            return Collections.emptyMap();
        }
        return toDisplays(fieldDiffViewService.build(
                valueContext, JsonUtil.getObjectToString(items), labels));
    }
 
    /**
     * 把一行里待渲染的字段追加成合成 diff 的项。**每一项的 field 必然非空**——
     * 这是 {@link #renderLines} 按位置分发的前提({@code build} 只在 field 缺席时丢项)。
     */
    private static void appendItems(List<Map<String, Object>> items, List<String> fields,
                                    Map<String, Object> row, Set<String> excludedFields) {
        if (fields == null || row == null) {
            return;
        }
        for (String field : fields) {
            if (field == null) {
                continue;
            }
            String lower = field.toLowerCase(Locale.ROOT);
            if (lower.isEmpty()) {
                continue;
            }
            if (excludedFields != null && excludedFields.contains(lower)) {
                continue;
            }
            String raw = AuditFieldDiff.stringify(valueOf(row, lower));
            if (raw.trim().isEmpty()) {
                continue;
            }
            Map<String, Object> item = new LinkedHashMap<String, Object>();
            item.put("field", lower);
            item.put("newData", raw);
            item.put("type", 0);
            items.add(item);
        }
    }
 
    /** 解析结果 → 字段名到展示值的映射。**调用方须保证这批 view 来自同一行**(字段名会跨行重复)。 */
    private static Map<String, String> toDisplays(List<AuditFieldDiffViewVO> views) {
        Map<String, String> out = new LinkedHashMap<String, String>();
        if (views == null) {
            return out;
        }
        for (AuditFieldDiffViewVO view : views) {
            if (view.getField() == null || Boolean.TRUE.equals(view.getMasked())) {
                continue;   // 双保险:解析侧判定为凭证的,即使调用方漏排除也不进标题
            }
            String display = view.getNewDisplay() != null && !view.getNewDisplay().isEmpty()
                    ? view.getNewDisplay() : view.getNewData();
            if (display != null && !display.trim().isEmpty()) {
                out.put(view.getField().toLowerCase(Locale.ROOT), display.trim());
            }
        }
        return out;
    }
 
    /**
     * 取子表行;键缺席或形态不对返回 null(= 不可得),空数组返回空列表(= 确实没有子记录)。
     *
     * <p><b>{@code limit} 是本方法的资源上界</b>(2026-08-05 Codex 复审 Medium④):**物化时就截断**,
     * 不是先全量复制再交给调用方挑。调用方那道 {@link #MAX_SUB_SCAN} 只管得住「渲染几行」,
     * 管不住这里复制几行——一张 5000 行的子表照样会被整份搬进内存,而 MQ 消费线程上
     * 每条事件都要走一遍。
     *
     * <p><b>没被 limit 覆盖的那一半</b>:值是 JSON 字符串时 Jackson 仍要把整个数组解析完
     * (流式解析要换掉 {@link JsonUtil} 的入口,代价不成比例)。但那份原始文本本来就随事件
     * 报文一起在内存里了,真正会二次放大的是这里的对象复制与后续的合成 diff,两者都已按 limit 收住。
     */
    @SuppressWarnings("unchecked")
    private List<Map<String, Object>> subRows(Map<String, Object> row, String vModel, int limit) {
        Object raw = valueOf(row, vModel == null ? null : vModel.toLowerCase(Locale.ROOT));
        if (raw == null) {
            return null;
        }
        Object parsed = raw;
        if (raw instanceof String) {
            String text = ((String) raw).trim();
            if (text.isEmpty()) {
                return Collections.emptyList();
            }
            try {
                parsed = JsonUtil.getJsonToList(text, Map.class);
            } catch (Throwable t) {
                return null;
            }
        }
        if (!(parsed instanceof List)) {
            return null;
        }
        List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
        for (Object item : (List<?>) parsed) {
            if (out.size() >= limit) {
                break;   // 名额用尽:物化即止,不把整张子表搬进内存
            }
            if (item instanceof Map) {
                out.add((Map<String, Object>) item);
            }
        }
        return out;
    }
 
    private static Object valueOf(Map<String, Object> row, String lowerField) {
        if (row == null || lowerField == null) {
            return null;
        }
        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 containsIgnoreCase(Set<String> values, String expected) {
        if (values == null || expected == null) {
            return false;
        }
        for (String value : values) {
            if (value != null && expected.equals(value.toLowerCase(Locale.ROOT))) {
                return true;
            }
        }
        return false;
    }
 
    private static String truncate(String value, int maxLength) {
        return value.length() <= maxLength ? value : value.substring(0, maxLength);
    }
}