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}
* 走的同一条展示解析路径,保证标题与字段明细里的同一个字段显示成同一个值。
*
*
三类字段绝不进标题(顺序即优先级):
*
* - 调用方传入的 {@code excludedFields}——脱敏列与判定为签名字段的列。
* 标题是列表明文列,凭证/敏感值进去就洗不掉(审计表 append-only 保留 10 年);
* - 展示解析后仍被标记 {@code masked} 的——双保险,防调用方漏传;
* - 值为空的——跳过而不是留一个空的 {@code /} 段。
*
*/
@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