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);
|
}
|
}
|