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; /** * 把表单勾选渲染成人可读的「操作记录标题」。 * *

用展示值不用存储值:字典/引用/枚举字段的存储值是雪花 ID 或字典码,直接拼进标题 * 就是一串数字。这里复用 {@link AuditFieldDiffViewService#build}——也就是 {@code field_diffs} * 走的同一条展示解析路径,保证标题与字段明细里的同一个字段显示成同一个值。 * *

三类字段绝不进标题(顺序即优先级): *

    *
  1. 调用方传入的 {@code excludedFields}——脱敏列与判定为签名字段的列。 * 标题是列表明文列,凭证/敏感值进去就洗不掉(审计表 append-only 保留 10 年);
  2. *
  3. 展示解析后仍被标记 {@code masked} 的——双保险,防调用方漏传;
  4. *
  5. 值为空的——跳过而不是留一个空的 {@code /} 段。
  6. *
*/ @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 合批后新增)。 * *

比 {@link #MAX_SUB_ROWS} 大一档:整行标题字段为空的行渲染不出标题却仍要占一个扫描名额, * 两者同值时「前 200 行恰好全空」就会让 recordSubTitles 直接空掉(Codex 终审缺陷 6)。 * 放大到 4 倍是权衡——够吸收常见的空行比例,又把合成 diff 的规模钉在常数级。 * *

2026-08-05 起它同时是物化预算:{@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 row, AuditEventEntity valueContext, Map labels, Set excludedFields) { return build(spec, row, valueContext, labels, excludedFields, Collections.emptyMap()); } public AuditRecordTitle build(AuditTitleSpec spec, Map row, AuditEventEntity valueContext, Map labels, Set excludedFields, Map 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> lineFields = new ArrayList>(); List> lineRows = new ArrayList>(); 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> 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 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 lines = renderLines(lineFields, lineRows, valueContext, labels, excludedFields, frontendDisplayValues); List subTitles = new ArrayList(); // 产出侧截断同样留痕:这道上限从改动前就是**悄悄**截的,读的人看到 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 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}。 * *

为什么按位置分发而不是按字段名:字段名在跨行时会重复(每个子表行都有同一批列), * 按名字建 Map 会互相覆盖。{@code build} 逐项等长返回且保序——它只在 {@code field} 缺席时 * 丢项,而这里每一项都带非空 field,所以位置是可靠的键。 * *

等长前提一旦不成立就退回逐行解析:多查几次只是慢,而错位会把 A 行的值显示到 * B 行的标题上——{@code record_title} 是 append-only 存十年的明文列,写错洗不掉。 */ private List renderLines(List> lineFields, List> lineRows, AuditEventEntity valueContext, Map labels, Set excludedFields, Map frontendDisplayValues) { List> items = new ArrayList>(); List starts = new ArrayList(); for (int i = 0; i < lineRows.size(); i++) { starts.add(items.size()); appendItems(items, lineFields.get(i), lineRows.get(i), excludedFields); } List out = new ArrayList(lineRows.size()); if (items.isEmpty()) { for (int i = 0; i < lineRows.size(); i++) { out.add(null); } return out; } List 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 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 fields, Map row, AuditEventEntity valueContext, Map labels, Set excludedFields, Map frontendDisplayValues) { Map 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 displays, List configuredFields, Set excludedFields, Map 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 fields, Map 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}。 * *

{@code "type": 0} 是 field_diffs 契约里的"新增",让解析侧只看 newData 那一侧。 */ private Map displayValues(List fields, Map row, AuditEventEntity valueContext, Map labels, Set excludedFields) { List> items = new ArrayList>(); 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> items, List fields, Map row, Set 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 item = new LinkedHashMap(); item.put("field", lower); item.put("newData", raw); item.put("type", 0); items.add(item); } } /** 解析结果 → 字段名到展示值的映射。**调用方须保证这批 view 来自同一行**(字段名会跨行重复)。 */ private static Map toDisplays(List views) { Map out = new LinkedHashMap(); 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(= 不可得),空数组返回空列表(= 确实没有子记录)。 * *

{@code limit} 是本方法的资源上界(2026-08-05 Codex 复审 Medium④):**物化时就截断**, * 不是先全量复制再交给调用方挑。调用方那道 {@link #MAX_SUB_SCAN} 只管得住「渲染几行」, * 管不住这里复制几行——一张 5000 行的子表照样会被整份搬进内存,而 MQ 消费线程上 * 每条事件都要走一遍。 * *

没被 limit 覆盖的那一半:值是 JSON 字符串时 Jackson 仍要把整个数组解析完 * (流式解析要换掉 {@link JsonUtil} 的入口,代价不成比例)。但那份原始文本本来就随事件 * 报文一起在内存里了,真正会二次放大的是这里的对象复制与后续的合成 diff,两者都已按 limit 收住。 */ @SuppressWarnings("unchecked") private List> subRows(Map 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> out = new ArrayList>(); for (Object item : (List) parsed) { if (out.size() >= limit) { break; // 名额用尽:物化即止,不把整张子表搬进内存 } if (item instanceof Map) { out.add((Map) item); } } return out; } private static Object valueOf(Map row, String lowerField) { if (row == null || lowerField == null) { return null; } 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 containsIgnoreCase(Set 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); } }