package jnpf.bizcommon.audit.service.listener;
|
|
import cn.hutool.http.useragent.UserAgent;
|
import cn.hutool.http.useragent.UserAgentUtil;
|
import com.alibaba.fastjson.JSON;
|
import jnpf.audit.AuditApiConsts;
|
import jnpf.audit.AuditOperationIds;
|
import jnpf.audit.model.AuditEventDTO;
|
import jnpf.audit.AuditConsts;
|
import jnpf.bizcommon.audit.entity.AuditEventEntity;
|
import jnpf.audit.diff.AuditFieldDiff;
|
import jnpf.bizcommon.audit.mapper.AuditEventMapper;
|
import jnpf.bizcommon.audit.service.AuditEventService;
|
import jnpf.bizcommon.audit.service.AuditFieldDiffViewService;
|
import jnpf.bizcommon.audit.service.AuditFormRegistry;
|
import jnpf.bizcommon.audit.service.entry.AuditEntryResolver;
|
import jnpf.bizcommon.audit.service.sign.AuditSignEvidence;
|
import jnpf.bizcommon.audit.service.sign.AuditSignEvidenceReader;
|
import jnpf.bizcommon.audit.service.sign.AuditSignMetaData;
|
import jnpf.bizcommon.audit.service.title.AuditRecordTitle;
|
import jnpf.bizcommon.audit.service.title.AuditRecordTitleBuilder;
|
import jnpf.bizcommon.audit.service.title.AuditTitleSpec;
|
import jnpf.constant.JnpfConst;
|
import jnpf.event.ProjectEventListener;
|
import jnpf.module.ProjectEventInstance;
|
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.Arrays;
|
import java.util.Collection;
|
import java.util.Collections;
|
import java.util.Date;
|
import java.util.Iterator;
|
import java.util.LinkedHashMap;
|
import java.util.LinkedHashSet;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Map;
|
import java.util.Set;
|
|
/**
|
* 层 0:在线表单变更事件 → {@code audit_events}(Task C1)。
|
*
|
* <p>jnpf-visualdev 在 {@code VisualLogServiceImpl.createEventLog} 以 BROADCASTING 发布
|
* {@code VSLOG_EVENT_KEY} 事件(平台补丁 #1),经 RocketMQ 跨 JVM 到达本服务。本类是 lims 侧
|
* {@code BaseVisualLogListener} 的迁入版,与它<b>同时订阅</b>——存储天然隔离(新写 audit_events、
|
* 旧写 lims_biz_log),影子期两条链路互不干扰。
|
*
|
* <h3>三条防御是照着实测教训写的,改动前先读 tasks/lessons.md</h3>
|
* <ul>
|
* <li><b>L002</b>:整个 handler 包 try/catch(Throwable)。JNPF 的 {@code ProjectEventProccess}
|
* 是 for-iterator 分发,任一 consumer 抛异常会 break 整个循环——同 channel 上的其它
|
* listener(含 lims 旧 listener)会跟着一起哑掉。审计绝不能拖垮别人。</li>
|
* <li><b>L003</b>:UPDATE 使用 visualdev 在数据库写入前、写入后分别查询得到的
|
* {@code oldData}/{@code newData}。两份数据必须随同一事件固化,MQ 消费端不回读业务表,
|
* 避免异步延迟读到后续操作产生的未来状态。</li>
|
* <li><b>L004</b>:跨 JVM 后 source 是 {@code JSONObject},当 Map 取。
|
* {@code VisualLogForm} 用了 {@code @Builder} 没有无参构造器,反序列化不回去。</li>
|
* </ul>
|
*
|
* <h3>数据库写入边界快照</h3>
|
* <p>每条层 0 事件仍在 {@code data_snapshot} 保存操作后的数据,但 UPDATE 的
|
* {@code field_diffs} 直接比较事件中的 {@code oldData}/{@code newData}。字段只存在于新数据时,
|
* 旧值按空处理并记录为新增;只存在于旧数据时,记录为清空。不再查询上一条审计日志作为基线。
|
*
|
* <h3>与旧 listener 的一处**刻意不同**(A/B 比对必须知道)</h3>
|
* <p>旧 listener 把 {@code jianyan_liushuihao} 当硬过滤器:取不到就整条丢弃。本实现<b>不跟</b>——
|
* bizCode 取不到就留空,事件照记。理由是审计的红线是「这次改过不能缺」(B3 确立),而
|
* {@code jianyan_liushuihao} 是 LIMS 检验流水号,环境监测/水系统等模块的表单根本没有这个字段,
|
* 按旧口径它们一条都不会被记。<b>后果</b>:同一批表单上 {@code audit_events} 会比
|
* {@code lims_biz_log} 多出事件,C2 的配对率算法必须按此口径解读,否则会把「新表更全」
|
* 误判成「配对失败」。
|
*/
|
@Slf4j
|
@Component
|
public class AuditVisualLogListener {
|
|
/** 幂等键前缀。广播模式下每个实例都会收到同一条事件,靠 UNIQUE(client_event_id) 挡重复。 */
|
private static final String EVENT_ID_PREFIX = "L0-";
|
|
/** 层 0 事件的 app_name = 事实产生进程;用户视角的「在哪个应用上」由 biz_module 承担(spec §5.4)。 */
|
private static final String SOURCE_APP = "jnpf-visualdev";
|
|
private static final String UNKNOWN = "UNKNOWN";
|
|
/**
|
* 平台技术列的保留前缀。业务规范禁止业务字段以此开头(仓库 CLAUDE.md「Conventions」),
|
* 故可安全按前缀剔除,不需要维护一张会漂移的列名白名单。详见 {@link #withoutTechColumns}。
|
*/
|
private static final String TECH_COLUMN_PREFIX = "f_";
|
|
/**
|
* 虽带平台前缀,但它们描述的是业务记录的创建/修改责任人和时间,属于审计事实。
|
* 这些字段必须进入 field_diffs,不能随其余平台技术列一起剔除。
|
*/
|
private static final Set<String> AUDITED_PLATFORM_COLUMNS = Collections.unmodifiableSet(
|
new LinkedHashSet<>(Arrays.asList(
|
"f_creator_user_id",
|
"f_creator_time",
|
"f_last_modify_user_id",
|
"f_last_modify_time")));
|
|
/**
|
* 签名 id 来自请求顶层(非数据字段)时,落进 {@code signExtra.signField} 的<b>展示标签</b>。
|
*
|
* <p>🔴 <b>它只是标签,不是判据</b>(2026-08-05 Codex 复审 High①)。此前它同时兼着
|
* 「来源标记」与 {@code Map<String,String> signFieldToId} 的 <b>key</b>,于是「够不够格
|
* 改写 action_code」这件事被编码在了一个<b>可碰撞的字符串</b>里:业务表只要真有一个
|
* 同名列,它的数据列证据就会被当成请求来源({@code reviewFromRequest=true} → 把普通
|
* UPDATE 永久写成 REVIEW),而且 {@code reordered.putAll} 会让数据列的值<b>覆盖</b>
|
* 请求值。全库实查当前 0 个同名列,是埋着的陷阱不是活的故障——但 L081 的规矩正是
|
* 「来源必须单独记成标记跟着值走」,用字段名编码来源等于把标记又塞回了内容里。
|
* 现在来源是 {@link SignCandidate#fromRequest} 这个独立布尔量,本常量退回纯展示用途,
|
* 值保持不变是为了历史数据的 {@code signExtra.signField} 语义连续。
|
*/
|
private static final String REQUEST_SIGN_FIELD = "__request_biz_sign__";
|
|
/**
|
* 本类产出的签名事件的 action_code 与 source_layer。
|
*
|
* <p><b>抽成常量是刻意的</b>(2026-08-03 Codex 终审缺陷 2):A1-b 的历史反查必须按
|
* 与写入侧**完全一样**的四元组去查(表名 + event_type + action_code + source_layer)。
|
* 实测同一张 {@code lims_sign} 上,层 2 也写 {@code action_code=SIGN}、层 3 还写
|
* {@code SIGN_USED}、层 2 写 {@code SIGN_FAIL}——少钉一个维度就会把别的层记的事件
|
* 算成「本 listener 记过了」,真实的复核签名被判成历史、本次签名事件被静默抑制。
|
* 两处分别写字面量,改一处漏一处不报任何错。
|
*/
|
private static final String SIGN_ACTION_CODE = "SIGN";
|
|
/** 本 listener 补写的签名属于显式签名事实层;反查与写入共用,理由同 {@link #SIGN_ACTION_CODE}。 */
|
private static final int SIGN_SOURCE_LAYER = 3;
|
|
/**
|
* extra.frontendBizData 的长度上限。extra 列不在 {@code AuditEventServiceImpl.clip} 的
|
* 白名单里,没有任何护栏——批量场景下 N 条事件共享同一份整批 biz_data 快照,
|
* 不设上限会线性叠加到百 MB 级(见调用处注释的实测数字)。
|
*/
|
private static final int FRONTEND_BIZ_DATA_MAX = 8192;
|
|
/**
|
* 请求携带的签名「多新才算新鲜」(批 D,2026-08-05)。**超出只留痕、不拒绝**。
|
*
|
* <p>取 30 分钟是权衡:用户点签名到点确定之间要填表、翻页、被打断,卡太紧会把正常操作
|
* 误报成可疑;而重放攻击真正的克星是「一次性消费」(签过就作废)而不是时间窗——
|
* 那要真加消费状态,属批 D 完整版。这里只把年龄如实摆出来给查询侧判断,
|
* 所以阈值宽一点也不会掩盖什么:真要查,{@code signAuthStaleMillis} 里有确切毫秒数。
|
*/
|
private static final long SIGN_FRESH_WINDOW_MILLIS = 30L * 60 * 1000;
|
|
@Autowired
|
private AuditEventService auditEventService;
|
|
@Autowired
|
private AuditEventMapper auditEventMapper;
|
|
@Autowired
|
private AuditFormRegistry registry;
|
|
@Autowired
|
private AuditFieldDiffViewService fieldDiffViewService;
|
|
@Autowired
|
private AuditRecordTitleBuilder recordTitleBuilder;
|
|
@Autowired
|
private AuditSignEvidenceReader signEvidenceReader;
|
|
@Autowired
|
private AuditEntryResolver entryResolver;
|
|
@ProjectEventListener(channelRegex = JnpfConst.VSLOG_EVENT_KEY + ".*")
|
public void onVisualLog(ProjectEventInstance event) {
|
try {
|
handle(event);
|
} catch (Throwable t) {
|
// L002:绝不让异常出去——同 channel 上的其它 listener 是全有或全无的命运共同体
|
log.error("[AUDIT-L0] 处理 VSLOG 事件失败(业务与其它监听者不受影响)eventId={}",
|
event == null ? null : event.getEventId(), t);
|
}
|
}
|
|
private void handle(ProjectEventInstance event) {
|
if (!registry.isEnabled() || event == null) {
|
return;
|
}
|
Map<String, Object> form = asMap(event.getSource());
|
if (form == null) {
|
return;
|
}
|
String modelId = str(form.get("modelId"));
|
if (modelId == null) {
|
return;
|
}
|
AuditFormRegistry.FormMeta meta = registry.resolve(modelId);
|
if (meta == null) {
|
return; // 清单外的表单:默认不记,正常路径,不留痕(否则每次保存都刷一行日志)
|
}
|
// 直接读 meta.getTable(),不再调一次 resolveMainTable(评审 M-10:resolve() 内部已解析过一次)。
|
// 安全性来自 AuditFormRegistry.setForms 会把 table 归一化写回 meta——两者是同一个小写表名。
|
String table = meta.getTable();
|
String dataId = str(form.get("dataId"));
|
|
Object typeRaw = form.get("type");
|
boolean create = typeRaw instanceof Number && ((Number) typeRaw).intValue() == 0;
|
// type=2 是 AUDIT 平台补丁(2026-08-01)新增的删除事件,平台原生只发 0/1。
|
boolean delete = typeRaw instanceof Number && ((Number) typeRaw).intValue() == 2;
|
boolean read = typeRaw instanceof Number && ((Number) typeRaw).intValue() == 3;
|
if (read && !registry.isRecordRead()) {
|
return;
|
}
|
String actionCode = read ? AuditConsts.ACT_READ
|
: (delete ? AuditConsts.ACT_DELETE
|
: (create ? AuditConsts.ACT_CREATE : AuditConsts.ACT_UPDATE));
|
|
// UPDATE 的两侧都是 visualdev 在数据库写入边界读取的快照;MQ 消费端不再查询历史审计快照。
|
Map<String, Object> oldData = asMap(form.get("oldData"));
|
Map<String, Object> newData = asMap(form.get("newData"));
|
// 两侧统一字符串化,避免 JDBC/JSON 数值与日期类型差异制造假 diff。
|
Map<String, Object> beforeRow = stringifyValues(oldData);
|
Map<String, Object> eventRow = stringifyValues(newData);
|
Map<String, AuditFieldDiff.FieldMeta> labels = AuditFormRegistry.mergeFieldMetadata(
|
registry.resolveFieldMetadata(modelId, table), fieldMeta(form.get("listLog")));
|
List<Map<String, Object>> childDiffs = childDiffs(form.get("listLog"));
|
Set<String> childFields = new LinkedHashSet<>(subTableFields(labels));
|
Set<String> masked = effectiveMasked(meta.getMaskedFields(), eventRow, labels);
|
masked.addAll(effectiveMasked(meta.getMaskedFields(), beforeRow, labels));
|
|
Map<String, Object> extra = new LinkedHashMap<>();
|
// C2 的就近配对靠这两个键 join 旧表的 source_model_id / source_data_id,别改名
|
extra.put("sourceModelId", modelId);
|
if (dataId != null) {
|
extra.put("sourceDataId", dataId);
|
}
|
|
AuditCtx ctx = AuditCtx.parse(form.get("auditCtx"), event.getTenantId(), extra);
|
if (!registry.acceptsRequest(ctx.appScope, ctx.appCode)) {
|
return;
|
}
|
extra.put("appScope", ctx.appScope);
|
extra.put("appCode", ctx.appCode);
|
|
// 操作入口:用户从哪个菜单点进来的。三种状态必须分得开(L075:「没携带」≠「不存在」):
|
// 没带 header → degraded(旧前端 / 非浏览器调用 / 定时任务)
|
// 带了但查不到菜单行 → unresolved(菜单被删了,但 entry_id 仍是可追溯的事实)
|
// 正常 → 不写标记
|
String entryType = null;
|
String entryId = null;
|
String entryName = null;
|
if (ctx.menuId == null) {
|
extra.put("entrySource", "degraded");
|
} else {
|
entryType = AuditEntryResolver.TYPE_MENU;
|
entryId = ctx.menuId;
|
entryName = entryResolver.resolveMenuName(ctx.menuId);
|
if (entryName == null) {
|
extra.put("entrySource", "unresolved");
|
}
|
}
|
|
if (read) {
|
recordRead(event, form, meta, table, dataId, ctx, extra,
|
entryType, entryId, entryName);
|
return;
|
}
|
|
String fieldDiffs = null;
|
if (!childDiffs.isEmpty()) {
|
extra.put("subTableDiffSource", "visualLogListLog");
|
} else if (!(form.get("listLog") instanceof List) && !childFields.isEmpty()) {
|
// 兼容补丁上线前或非标准发布方:没有 listLog 契约时仍如实标记不可判定。
|
extra.put("subTableUndecidable", new ArrayList<>(childFields));
|
}
|
// 脱敏列用数据库写入前后的真实值判等,确定发生变化后再由 AuditFieldDiff 将两侧值替换为 ***。
|
Map<String, Object> snapshotRow = eventRow;
|
if (eventRow == null) {
|
extra.put("degraded", "事件未携带 newData,本次变更内容不可得");
|
} else if (delete) {
|
// 删除不产 diff:没有 after 值,编出来的 diff 是假的(B3 红线)。
|
// 只留 data_snapshot——「删掉的是什么」正是删除审计的全部价值。
|
extra.put("snapshotSource", "eventNewData");
|
extra.put("snapshotScope", "deleted");
|
} else if (create) {
|
// CREATE 的 before 为 null ⇒ 每一列都算新增,故技术列必须在此剔掉(见 forDiff)
|
fieldDiffs = AuditFieldDiff.diff(null,
|
withoutFields(forDiff(eventRow), childFields), masked, labels);
|
extra.put("snapshotSource", "eventNewData");
|
// **平台把空值字段整个从 newData 里剥掉了**(2026-07-30 实测,见 knownEmptyFields 注释)。
|
// 只存事件携带的键 ⇒ baseline 天生缺字段 ⇒ 那些字段**第一次被填写**时会被 UPDATE 的
|
// 两侧交集排除,真实变更漏记。这里按已发布表单模型把「已知为空」显式补进快照,
|
// 让「已知为空」与「baseline 真正未知」从此是两种状态、不再共用一个"缺键"表示。
|
Set<String> formFields = registry.resolveBaselineFields(modelId, table);
|
List<String> knownEmpty = knownEmptyFields(eventRow, formFields, masked, labels);
|
snapshotRow = withKnownEmpty(eventRow, knownEmpty);
|
// 补没补、补了哪些,一律留痕:补齐依赖表单元数据可读,取不到时会静默退回旧行为,
|
// 而"审计有洞而无人知道"正是这套代码里反复被咬的那个坑(同 whoSource/timeSource)。
|
// **按 formFields 而不是按 knownEmpty 判**:整份表单都填满了(无需补)与
|
// 元数据根本取不到(补不了)是两回事,混成一个值就分辨不出快照到底完不完整。
|
extra.put("baselineScope", formFields.isEmpty() ? "eventOnly" : "formModel");
|
if (!knownEmpty.isEmpty()) {
|
extra.put("baselineKnownEmpty", knownEmpty);
|
}
|
} else {
|
extra.put("snapshotSource", "eventNewData");
|
if (beforeRow == null) {
|
extra.put("snapshotDegraded", true);
|
extra.put("diffSource", "missingOldData");
|
} else {
|
fieldDiffs = databaseBoundaryDiff(beforeRow, eventRow, masked, labels, childFields);
|
extra.put("diffSource", "databaseBeforeAfter");
|
}
|
}
|
|
fieldDiffs = appendChildDiffs(fieldDiffs, childDiffs);
|
|
if (fieldDiffs != null) {
|
AuditEventEntity valueContext = new AuditEventEntity();
|
valueContext.setTargetTable(table);
|
valueContext.setTenantId(ctx.tenantId);
|
valueContext.setEventTime(ctx.eventTime);
|
valueContext.setActionCode(actionCode);
|
fieldDiffs = fieldDiffViewService.enrichForPersistence(
|
valueContext, fieldDiffs, labels);
|
}
|
|
// ── 标题规格分流(D6)───────────────────────────────────────────────────
|
// 表单操作读表单勾选,列表操作(删除)读列表勾选。前端把开关做在两个界面上,
|
// 用户的心智就是「在哪勾的在哪生效」。
|
// 注意:titleSpec 不止喂下面的标题渲染,也喂签名字段候选判定(见下一段注释)——
|
// 删除路径的签名候选因此也会跟着改读列表勾选,而不是表单勾选。命名规则命中的
|
// 凭证字段恒为候选(不看 spec,见 AuditSignEvidenceReader.candidateFields),
|
// 真正受影响的只是「表单勾了 + 列表没勾 + 命名规则又不命中」这类字段:
|
// 它们在删除事件里会从签名候选中掉出去。
|
AuditTitleSpec titleSpec = delete
|
? registry.resolveListTitleSpec(modelId, table)
|
: registry.resolveTitleSpec(modelId, table);
|
|
// ── 签名字段判定(spec §5.4,D4)──────────────────────────────────────────
|
// 权威来源是表单配置 confirmBtnConfig/reviewBtnConfig 的 biz_sign_field(D4)——
|
// 多人签名场景下字段名可以是任意的,光靠命名规则+勾选会漏。命名规则命中的字段与
|
// 勾选字段仍保留作兜底:同一个 biz_log_enabled 开关能同时服务「进标题」与「是签名」
|
// 两种语义,而不需要前端再加一个开关。三条来源合并后仍要拿值去签名表撞一下,
|
// 命中的才真正算签名字段(防误判的最后一关,配置不代表这次真的触发了签名)。
|
List<String> configuredSignFields = registry.resolveSignFields(modelId, table);
|
List<String> signCandidateFields = AuditSignEvidenceReader.candidateFields(
|
eventRow, titleSpec, configuredSignFields);
|
// 🔴 候选是**带来源标记的三元组列表**,不是 field→signId 的 Map(2026-08-05 Codex
|
// 复审 High①,理由见 REQUEST_SIGN_FIELD 的 javadoc)。顺序即优先级,与原先
|
// LinkedHashMap 的迭代序一致;来源由 SignCandidate.fromRequest 承担,与字段名解耦。
|
List<SignCandidate> signCandidates = new ArrayList<>();
|
Map<String, AuditSignEvidence> evidences = Collections.emptyMap();
|
List<String> ids = new ArrayList<>();
|
/** 请求声称携带、但在签名表里查不到的那个 id(不存在/已删/跨租户)。见剔除处的注释。 */
|
String requestSignNotFound = null;
|
// Task 1 平台补丁把「本次操作」的签名 id 放在事件顶层(删除路径前端只能这么传,
|
// 因为被删数据里的签名字段存的是创建/编辑时的历史签名,不是这次删除的)。
|
// signExtra.signField 落 REQUEST_SIGN_FIELD 这个标签,查询时能看出这条签名是
|
// 「请求携带」还是「数据字段里读到的」——但判定一律走 fromRequest,不认标签。
|
// **先加、排在最前**:它是本次操作最权威的签名来源,撞 MAX_IDS 上限时先保住它
|
// (原先靠 reordered.putAll + ids.add(0,…) 事后重排,现在靠添加顺序天然成立)。
|
String eventBizSign = str(form.get("bizSign"));
|
if (eventBizSign != null && !eventBizSign.trim().isEmpty()) {
|
signCandidates.add(new SignCandidate(REQUEST_SIGN_FIELD, eventBizSign.trim(), true));
|
ids.add(eventBizSign.trim());
|
}
|
for (String field : signCandidateFields) {
|
String value = AuditFieldDiff.stringify(lookup(eventRow, field)).trim();
|
if (!value.isEmpty()) {
|
signCandidates.add(new SignCandidate(field, value, false));
|
ids.add(value);
|
}
|
}
|
if (!ids.isEmpty()) {
|
// 按租户读(批 D):f_id 单列唯一不是数据库不变量,跨租户的 signId 直接读不出来,
|
// 于是候选在下面被剔掉、连签名事件都不会产出。见 read 的 javadoc。
|
evidences = signEvidenceReader.read(ids, ctx.tenantId);
|
// 没命中签名表的候选不是签名字段,剔回标题侧。
|
Iterator<SignCandidate> pruning = signCandidates.iterator();
|
while (pruning.hasNext()) {
|
SignCandidate pruned = pruning.next();
|
if (!evidences.containsKey(pruned.signId)) {
|
// 🔴 数据列候选落空是**正常**的(那个字段本来就不是签名字段,剔回标题侧即可);
|
// 但**请求声称携带了签名、凭证却查不到**是另一回事:不存在、已删、
|
// 或属于别的租户(D1 收窄之后跨租户的正是在这里落空)。
|
// 不留痕的话它与「请求压根没带签名」长得一模一样——等于把一次可疑的声称
|
// 洗成了「本来就没签」,而且**越高明的攻击反而越安静**(跨租户静默、
|
// 冒用有痕),方向正好反了。(L075:两种缺席不能共用一个表示。)
|
if (pruned.fromRequest) {
|
requestSignNotFound = pruned.signId;
|
}
|
pruning.remove();
|
}
|
}
|
}
|
// ── 请求携带签名的授权判定(批 D,2026-08-05,Codex 终审 High①)────────────────
|
// 修复前 form.bizSign 恒为 null,这条无校验的信任路径是**死的**;是 P1a 把它接通的。
|
// 接通之后,请求里塞任意一个已知的 signId 都会被当成「本次操作的签名」,而签名事件的
|
// operatorId 取的是**那条凭证的主人**——等于凭一个 id 就能把「谁在何时签的名」这个
|
// 事实伪造进 append-only、存十年的审计表。实测库里有 5 个用户的签名共 1306 条。
|
//
|
// 本层只做**审计侧**的收口(用户 2026-08-05 拍板):listener 跑在 MQ 消费端、是异步的,
|
// 拦不住那次删除已经发生——但它能拒绝把伪造的归属**写进审计**,而这正是审计的本职。
|
// 同步拒绝请求要改 jnpf-visualdev 控制器(平台补丁),另开一轮。
|
//
|
// 判据是免费的:evidence.operatorId() 就是 lims_sign.f_creator_user_id,ctx.userId 就在手边。
|
for (SignCandidate candidate : signCandidates) {
|
if (!candidate.fromRequest) {
|
continue; // 数据列证据不走这条路:它们本来就要过历史反查那一关
|
}
|
AuditSignEvidence evidence = evidences.get(candidate.signId);
|
if (evidence == null) {
|
continue;
|
}
|
String owner = evidence.operatorId();
|
if (owner == null || ctx.userId == null || UNKNOWN.equals(ctx.userId)) {
|
// 判不了 ≠ 判非法(L075)。auditCtx 降级(旧前端/非浏览器调用)时拿不到操作人,
|
// 这时拒绝会把正常路径一起打死。**不拒绝,但如实留痕**——留痕本身就是线索:
|
// 「这条签名归属没被验证过」与「验证通过了」在查询侧必须分得开。
|
extra.put("signAuthUndecidable", owner == null ? "signOwnerUnknown" : "operatorUnknown");
|
continue;
|
}
|
if (!owner.equals(ctx.userId)) {
|
// 🔴 **只留痕,不拒绝**(2026-08-05 Codex 补审,用户拍板)。
|
// 第一版这里判「冒用」并拒收,那是**错的**——`POST /lims/biz/sign/verify_user`
|
// (LimsSignServiceImpl.verifyUserAndRecord)是「指定用户签名」的正规入口:
|
// 操作人 A 在浏览器前操作,复核人 B 走过来输自己的账号密码完成复核签名,
|
// 凭证主人是 B、发请求的 session 仍是 A,`allow_self != yes` 时甚至**强制**
|
// 两者不同。这是标准的 GMP 多人会签,全库 1306 条签名里就有 5 个不同的主人。
|
// 本仓库自己的 docs/handoff-2026-07-29-audit-query-semantics.md 早就写着:
|
// 「creator_user_id …**对 verify_user 场景不能只看当前请求操作人**」。
|
//
|
// 那能不能补个判据把「会签」与「盗用 signId」分开?**不能**——两者在数据上
|
// 长得一模一样:lims_sign 没有任何列记录「谁取得了这份凭证」,层 2 的 SIGN
|
// 事件只覆盖 8/1306(0.6%)。信息不在表里,加谓词补不了(批 A 方案 B 同款形状)。
|
// 硬判的代价是把一次**真实的合规会签**明文记成「伪造尝试」,而审计表 append-only
|
// 洗不掉——**「错的判成了」比「没判」更坏**(L075)。
|
//
|
// 所以审计层的本分是**把不一致照出来,而不是拦下来**:签名事件照常产出、
|
// 正确记在签名人名下(会签的复核签名不能丢),同时如实标注「签名人≠操作人」,
|
// 让审计员自己看。真正的拦截属于平台层同步校验(另开一轮),
|
// 根治属于批 D 完整版(服务端消费签名时显式记归属 + 一次性消费)。
|
Map<String, Object> diff = new LinkedHashMap<>();
|
diff.put("signField", candidate.signField);
|
diff.put("signId", candidate.signId);
|
diff.put("signOwner", owner);
|
diff.put("operator", ctx.userId);
|
extra.put("signerDiffersFromOperator", diff);
|
continue;
|
}
|
// 陈旧签名**只留痕不拒绝**:硬时间阈值会误伤慢用户与时钟偏移,而重放的根治是
|
// 「一次性消费」(签过就不能再用),那要真加消费状态——批 D 完整版的活。
|
// 这里只把「这份凭证签于多久以前」如实摆出来,让查询侧自己判断。
|
long ageMillis = ctx.eventTime != null && evidence.signedAt() != null
|
? ctx.eventTime.getTime() - evidence.signedAt().getTime() : -1L;
|
if (ageMillis > SIGN_FRESH_WINDOW_MILLIS) {
|
extra.put("signAuthStaleMillis", ageMillis);
|
}
|
}
|
// 「哪些字段不许进标题」——请求携带的那条也放进来(它不对应真实列,多排除一个
|
// 不存在的名字无害;万一业务真有同名列,排除是安全的那个方向)。
|
Set<String> signFields = new LinkedHashSet<>();
|
for (SignCandidate candidate : signCandidates) {
|
signFields.add(candidate.signField);
|
}
|
|
// 批量场景判定提前到这里:下面的 frontendBizData 膨胀防护要用,D3 的标题后缀
|
// (见后文)也要用,避免同一个 form 字段读两次。
|
Integer batchSize = form.get("batchSize") instanceof Number
|
? ((Number) form.get("batchSize")).intValue() : null;
|
|
// 批量删除时 N 条事件共享 batchOpId 当 operationId,查询侧
|
// 写入侧按 operation_id + event_category 物理合并;operation_id 代表完整前端操作。
|
// clientEventId 仍按事件唯一,否则撞 uni_audit_events_client_event_id。
|
// **位置上移**(2026-08-03,A1-b):下面的历史签名反查要拿它当「排除本次操作」的判据,
|
// 而它原先算在 DTO 组装处。只读 form 字段,上移无副作用。
|
String batchOpId = str(form.get("batchOpId"));
|
// operationId 的事实源与层 2 @AuditLog 保持一致:优先使用请求线程固化的请求头。
|
// 签名事件本身仍只根据 evidences 生成;关联业务日志不等于认可签名凭证。
|
String correlatedBizSign = ctx.auditBizSign != null ? ctx.auditBizSign : eventBizSign;
|
String correlatedOperationId = AuditOperationIds.correlation(correlatedBizSign);
|
String operationId = correlatedOperationId != null ? correlatedOperationId
|
: ((batchOpId != null && !batchOpId.trim().isEmpty())
|
? batchOpId.trim() : EVENT_ID_PREFIX + event.getEventId());
|
|
// ── 本次操作签的 vs 行里留着的历史签名(A1-b,2026-08-03 Codex 终审 High③)──────
|
// L078 立的规矩原先只在删除路径生效(P1b),非删除路径恒判「本次」。真实数据形态下
|
// 这是错的(本轮实测):
|
// · 用户点一次「复核」再点「确定」会生成**两条**签名,分别落进 fuhe_sign / biz_sign;
|
// · biz_sign 列每次保存都会刷新成新生成的确定签名,所以它几乎不可能是历史;
|
// · 真正会变成历史的是 fuhe_sign 这类**只在复核那次写、之后普通保存不再更新**的列——
|
// 之后每次普通保存都会把它重发一条签名事件,event_time 停在上次复核那一刻,
|
// 却挂在这次保存的 operation 下。这正是 L078 骂的时间倒流,只是换到了更新路径。
|
//
|
// 判据取自 audit_events 而不是 lims_sign.meta_data 的 biz_data(用户 2026-08-03 拍板 D7):
|
// meta_data 整块由前端 POST、后端 LimsSignServiceImpl 原样落库、零校验,拿它判定等于
|
// 把「凭空产出一条签名事件」的口子交给前端报文;而「这条签名之前记过没有」这个信息
|
// 本来就只存在于审计表里。也别想拿 baseline 比对签名列有没有变——快照里签名列是 `***`。
|
List<Map<String, Object>> historicalSignRefs = new ArrayList<>();
|
List<Map<String, Object>> signAuthRejected = new ArrayList<>();
|
if (!signCandidates.isEmpty()) {
|
// 删除路径**不查**:数据列候选恒判历史(P1b 规则不动),批量删 N 条一次都不查。
|
Set<String> recorded = Collections.emptySet();
|
if (!delete) {
|
List<String> dataFieldIds = new ArrayList<>();
|
for (SignCandidate candidate : signCandidates) {
|
if (!candidate.fromRequest) {
|
dataFieldIds.add(candidate.signId);
|
}
|
}
|
if (!dataFieldIds.isEmpty()) {
|
recorded = recordedSignIds(dataFieldIds, operationId, ctx.tenantId);
|
if (recorded == null) {
|
// D8 fail-closed:查询失败时全判历史。append-only 表里的假签名洗不掉,
|
// 比一个带留痕的已知缺口更糟;与 AuditSignEvidenceReader.read 失败时
|
// 「宁可这次不产出签名事件」同口径。signId 仍进 historicalSignRefs,
|
// 信息不丢,只是从「签名事件」降级成「可追的引用」。
|
extra.put("signHistoryDegraded", true);
|
recorded = new LinkedHashSet<>(dataFieldIds);
|
}
|
}
|
}
|
// 判定结果**记在候选自己身上**,不再另建一个「字段名集合」——集合按名字装,
|
// 而名字既可能撞车(High①)也不是候选的身份;下游两个循环直接读这个标记。
|
for (SignCandidate candidate : signCandidates) {
|
candidate.thisOperation = signedByThisOperation(
|
delete, candidate.fromRequest, candidate.signId, recorded);
|
if (!candidate.thisOperation) {
|
historicalSignRefs.add(
|
historicalSignRef(candidate.signField, candidate.signId,
|
evidences.get(candidate.signId)));
|
}
|
}
|
}
|
// 请求声称携带的签名压根查不到(不存在/已删/跨租户)——同样是一次「声称签了名」
|
// 但拿不出凭证的事实,与冒用并列记进同一个键,让查询侧一处就能看全所有可疑声称。
|
if (requestSignNotFound != null) {
|
Map<String, Object> ref = new LinkedHashMap<>();
|
ref.put("signField", REQUEST_SIGN_FIELD);
|
ref.put("signId", requestSignNotFound);
|
ref.put("reason", "signNotFound");
|
ref.put("presentedBy", ctx.userId);
|
signAuthRejected.add(ref);
|
}
|
// 被拒的凭证如实留痕:操作照旧发生了(本层异步、拦不住),但审计表里绝不写下
|
// 一条「某某在此时签了名」的假事实——只记「有人提交了一份不属于自己的凭证」。
|
if (!signAuthRejected.isEmpty()) {
|
extra.put("signAuthRejected", signAuthRejected);
|
}
|
// A2(Codex 终审 Medium⑤):判为历史的候选留一份可追的引用。层 0 上线前的老数据、
|
// 或当年 MQ 丢过事件的记录,行内有 biz_sign 却没有对应的 SIGN 事件——删除后快照里
|
// 那一列被脱敏成 `***`,判定又把它跳过,关联就永久不可恢复了。
|
// **刻意不带本次的 operation/eventTime**:这些是历史事实的引用,不是本次操作的证据
|
// (L078 的规矩);signedAtMillis 是签名凭证自身的创建时刻。
|
if (!historicalSignRefs.isEmpty()) {
|
extra.put("historicalSignRefs", historicalSignRefs);
|
}
|
|
// ── 动作语义覆盖(spec D5)───────────────────────────────────────────────────
|
// meta_data 唯一的不可替代价值——「用户点的是哪个按钮」。层 0 从 type 只能分出
|
// CREATE/UPDATE/DELETE,分不出「复核」与「普通保存」,而复核在 GMP 语境下是
|
// 必须独立追溯的动作类型。多个签名证据只认第一份(一次操作只有一个动作语义)。
|
//
|
// ⚠️ 按 signFieldToId 的顺序取,不按 evidences.values():evidences 是
|
// `WHERE f_id IN (...)` 查回来的 Map,行序是数据库返回序,不保证等于 IN 列表顺序;
|
// 删除事件可能同时命中「本次删除的 bizSign(Step 2.5 已排最前)」和「被删数据里的
|
// 历史 biz_sign」两个候选,若按 evidences.values() 取第一个,选中哪份纯看数据库
|
// 返回顺序,会把 Step 2.5 精心建立的优先级架空。与下面的签名事件循环同一手法
|
// (signFieldToId.entrySet() + evidences.get(id)),那里已经这么写,这里跟齐。
|
//
|
// 🔴 删除场景不接受 meta_data 的覆盖,理由分两条,**别顺手把下面两处 delete 短路
|
// "优化"掉**(评审修复轮次 1,2026-08-02,Step 2.5 落地后才暴露的新组合,brief 从未
|
// 权衡过这个交叉):
|
// ① action_code:Step 2.5 把签名证据喂给了删除事件之后,只要那条 lims_sign 的
|
// is_review_button=true(业务把删除二次确认配成了复核按钮),action_code 就会从
|
// DELETE 被悄悄改写成 REVIEW——查询侧是 action_code 精确匹配,这条删除记录会从
|
// 「按 DELETE 筛」的结果里整个消失,直撞「不许丢掉『这次改过』」的审计红线。
|
// DELETE 是硬事实,任何 meta_data 都不能盖过它,REVIEW 覆盖必须带 !delete 短路。
|
// ② action_label:biz_button 的可信度分场景——is_biz_form=true 的「复核」「确定」是
|
// 平台语义、值域受控;is_biz_form=false 的「批量按钮」「列表按钮」是业务开发者在
|
// 列表设计器里随手起的 UI 文案,没人审。把它当审计动作名,
|
// action_code=DELETE + action_label=批量按钮 这种组合对合规审查员而言正是
|
// actionLabel(boolean) javadoc 骂过的「既不是模块也不是动作的混合物」。
|
// 所以调用处(见 dto.actionLabel(...))delete 恒显示「删除」,按钮名仍存进
|
// extra.bizButton 留档,只是不进这一列人读的动作名。
|
// 🔴 **先选定,再施加**,不再「取第一份就 break」(A1-a,2026-08-03 Codex 终审 High③)。
|
// 旧写法在真实数据形态下选错了:一次复核保存必然同时带确定签名(is_review_button=false)
|
// 与复核签名(is_review_button=true),而候选顺序由表单配置决定、实测确定那份恒排在前,
|
// 于是 REVIEW 永远轮不到——实测 audit_events id 101 出的是 action_code=CREATE +
|
// bizButton=「确定」,全库仅有的 3 条 REVIEW 全部来自 e2e 脚本人为构造的靶子记录
|
// (那条记录 biz_sign 列刻意为空),**真实用户走不到 REVIEW**。
|
// 「一次操作只认一份动作语义」的约束不变,变的是选哪一份:复核证据优先,没有才取第一份。
|
// 🔴 复核证据只有在它**真的能改写动作语义**时才优先(2026-08-03 端到端第二次实测补)。
|
// CREATE/DELETE 是生命周期硬事实、覆盖不了,那时仍取第一份——也就是触发这次保存/删除
|
// 的那个按钮。否则 extra.bizButton 会记成「复核」,而复核并没有产生这次数据变更
|
// (它自己那条签名事件已经独立记着了)。实测 audit_events id 146:新建时同时点了复核,
|
// CREATE 事件的 bizButton 被记成「复核」,把「用户点确定保存」这个事实盖掉了。
|
// **短路放在选证据这一步、不放在下游**:放下游就会出现「选中了却用不上」的中间态,
|
// action_code 与 extra.bizButton 各说各话。
|
// 🔴🔴 **REVIEW 只认请求携带的签名**(2026-08-03 Codex 终审缺陷 1/3/4,用户拍板走方案 B)。
|
// 反查 audit_events 只是「这次刚签的吗」的**代理指标**,而它两个方向都会错:
|
// · 查不到 ≠ 刚签的——层 0 上线前的老数据、当年 MQ 丢过事件的记录都查不到,
|
// 于是一次普通保存会被**永久**写成 REVIEW(append-only,改不回来);
|
// · 查得到 ≠ 历史——MQ 乱序时,同一条记录的两次操作谁先被消费不定,
|
// 后发生的那次可能先落库,把先发生的那次挤成「历史」。
|
// 信息本身不在那张表里,加谓词补不了。所以**动作语义这一侧退回删除路径的同口径**:
|
// 只有 REQUEST_SIGN_FIELD(请求顶层显式带下来的签名)才够格改写 action_code。
|
// 代价是前端补传之前 REVIEW 出不来——但那是诚实的「判不了」,不是错的「判成了」。
|
// 反查**保留**,只用在低风险的那一半:抑制重复的签名事件(错了顶多多发/少发一条,
|
// 且下次操作自愈,不会往业务事件上焊死一个错误的 action_code)。
|
// 根治方向是服务端在消费签名时显式记录归属(lims_sign.data_id 现在是空的),
|
// 排进批 D 与 High①(签名授权)一起做。
|
boolean reviewCanApply = !delete && !create;
|
String bizButtonLabel = null;
|
AuditSignMetaData md = null; // 选定的那一份动作语义;null = 本次操作没有签名证据
|
// md 是从数据列证据里选中的(= 它「是不是这次签的」由反查这个代理指标判出来,可能错)。
|
// 只影响 extra 留痕的可信度标注,不参与任何判定(2026-08-05 Codex 复审 Medium②)。
|
boolean mdFromDataField = false;
|
boolean reviewSeenButUntrusted = false;
|
// md 选中的那份复核证据**是不是请求携带的**。只看 md.isReviewButton() 不够——
|
// 兜底分支(md == null 时取第一份)照样会把数据列里的复核证据放进 md,
|
// 于是 action_code 又被它改写了(2026-08-03 端到端第三次实测,audit_events id 204)。
|
// 「够不够格改写」是**来源**属性,不是内容属性,必须单独记,不能从 md 反推。
|
boolean reviewFromRequest = false;
|
for (SignCandidate candidate : signCandidates) {
|
AuditSignEvidence evidence = evidences.get(candidate.signId);
|
// 判为历史的证据**只为 reviewUndecidable 留痕才解析**:parse 会整份 JSON 解出来
|
// 再把 biz_data 重新序列化一遍(实测可达数百 KB),批量删 N 条 × M 个历史签名列
|
// 全解析会压垮 MQ 消费线程;而 delete/create 路径上 reviewUndecidable 恒不产出
|
// (reviewCanApply=false),那里解析纯属白费。
|
if (evidence == null || (!candidate.thisOperation && !reviewCanApply)) {
|
continue;
|
}
|
AuditSignMetaData parsed = AuditSignMetaData.parse(evidence.metaData());
|
// 🔴 留痕条件**与历史判定解耦**(2026-08-05 Codex 复审 Medium②):数据列里的复核
|
// 证据无论被反查判成本次还是历史,动作语义都不认它、用户看到的都是「没有 REVIEW」,
|
// 那就都得留下「为什么没有」。此前这行挂在 thisOperation 守卫之后——被判成历史的
|
// 那条复核证据连 reviewUndecidable 都不留,把「判不了」又伪装成了「没发生」(L075)。
|
if (!candidate.fromRequest && parsed.isReviewButton()) {
|
reviewSeenButUntrusted = true;
|
}
|
if (!candidate.thisOperation) {
|
continue; // 历史签名的 meta_data 描述的是当时那次操作,不是这次
|
}
|
if (md == null) {
|
md = parsed; // 没有复核证据时的兜底 = 第一份(既有行为)
|
mdFromDataField = !candidate.fromRequest;
|
}
|
if (!parsed.isReviewButton()) {
|
continue;
|
}
|
if (candidate.fromRequest) {
|
md = parsed;
|
mdFromDataField = false;
|
reviewFromRequest = true;
|
break; // 请求携带的复核证据优先,找到即定
|
}
|
// 数据列里的复核证据:判不了它是不是这次签的,**不拿它改写 action_code**
|
// (留痕已在上面与历史判定解耦地做过了)。
|
}
|
if (reviewCanApply && reviewSeenButUntrusted && !AuditConsts.ACT_REVIEW.equals(actionCode)) {
|
extra.put("reviewUndecidable", "dataFieldOnly");
|
}
|
// REVIEW 覆盖同时短路 delete **与 create**(见上面 reviewCanApply):
|
// 「新建一条记录的同时点了复核」是真实工作流(实测 audit_events id 101、119),
|
// 而 CREATE 与 DELETE 一样是**生命周期硬事实**——按 action_code='CREATE' 筛时这条
|
// 记录的创建事实会整个消失,与下面注释①骂 DELETE 那条是同一个红线。
|
// 这个坑在 A1-a 之前就潜伏着(候选顺序里复核排前就会中招,只是实测确定恒排前所以
|
// 没现形),A1-a 把「复核证据优先」定死之后变成必现,由端到端第 ① 场景抓出。
|
// 复核这件事没有丢:复核签名照样产出独立的签名事件。
|
boolean reviewApplied = reviewCanApply && reviewFromRequest;
|
if (md != null) {
|
if (reviewApplied) {
|
actionCode = AuditConsts.ACT_REVIEW;
|
}
|
if (md.bizButton() != null && !md.bizButton().isEmpty()) {
|
extra.put("bizButton", md.bizButton()); // 机器可读留档,恒留
|
if (mdFromDataField) {
|
// 这份按钮名来自**数据列**证据,而「它是不是这次操作签的」是反查这个
|
// 代理指标判出来的——两个方向都可能错(老数据查不到 / MQ 乱序)。
|
// 请求携带的证据没有这个不确定性,两者必须在查询侧分得开
|
// (2026-08-05 Codex 复审 Medium②)。
|
extra.put("bizButtonSource", "dataField");
|
}
|
// 🔴 只有复核按钮的文案才够格当 action_label(2026-08-02 端到端实测修复)。
|
// 收窄前这里无条件覆盖,于是新增/编辑两次实测的 action_label 都是「确定」——
|
// 前端确定按钮上那个 UI 文案,把「新建」「编辑」这两个系统判定出来的动作词
|
// 顶掉了:action_code 还是对的(CREATE/UPDATE),只有给人读的这一列退化成了
|
// 界面文案。复核是唯一的例外,因为 is_review_button=true 时上面同时把
|
// action_code 改成了 REVIEW——动作码已经变了,动作名跟着走才自洽;而且这条
|
// 分支的 biz_button 值域受控(is_biz_form=true 的平台按钮),不是业务开发者
|
// 在列表设计器里随手起的名字。
|
// 判据用 reviewApplied 而不是 md.isReviewButton():上面那句「动作码已经变了、
|
// 动作名跟着走才自洽」只有在覆盖**真的发生了**时才成立。create/delete 被短路时
|
// action_code 仍是 CREATE/DELETE,动作名再显示「复核」就又成了那个
|
// 「既不是模块也不是动作的混合物」。两者绑同一个布尔量,不靠巧合对齐。
|
if (reviewApplied) {
|
bizButtonLabel = md.bizButton();
|
}
|
}
|
if (md.rawBizData() != null) {
|
// 批量场景下每条事件共享同一份整批快照,N 条事件各存一份就是 N² 膨胀
|
// (500 行 × 250KB × 500 条 ≈ 125MB,AUDIT_SNAPSHOT_LIMIT=500 时的实测量级),
|
// 而 extra 列不在 clip 白名单里、无护栏。批量时它对单条记录本来就没有指向性,
|
// 不写;单条时也设上限,防超大表单。
|
if (batchSize != null && batchSize > 1) {
|
extra.put("frontendBizDataOmitted", "batch");
|
} else {
|
String raw = md.rawBizData();
|
if (raw.length() > FRONTEND_BIZ_DATA_MAX) {
|
extra.put("frontendBizData", raw.substring(0, FRONTEND_BIZ_DATA_MAX));
|
extra.put("frontendBizDataTruncated", raw.length());
|
} else {
|
extra.put("frontendBizData", raw);
|
}
|
}
|
}
|
}
|
|
// ── 记录标题(spec §5.2)───────────────────────────────────────────────────
|
Set<String> titleExcluded = new LinkedHashSet<>(masked);
|
titleExcluded.addAll(signFields);
|
AuditEventEntity titleContext = new AuditEventEntity();
|
titleContext.setTargetTable(table);
|
titleContext.setTenantId(ctx.tenantId);
|
titleContext.setEventTime(ctx.eventTime);
|
titleContext.setActionCode(actionCode);
|
Map<String, String> frontendDisplayValues = frontendDisplayValues(
|
form.get("auditDisplayFields"), titleSpec, titleExcluded);
|
AuditRecordTitle title = recordTitleBuilder.build(
|
titleSpec, eventRow, titleContext, labels, titleExcluded, frontendDisplayValues);
|
if (!frontendDisplayValues.isEmpty()) {
|
extra.put("titleDisplaySource", "frontendSubmit");
|
}
|
String baseRecordTitle = recordTitleBuilder.resolveMainTitle(
|
titleSpec, title, eventRow, dataId);
|
if (titleSpec.isEmpty()) {
|
// 一个字段都没勾——当前库里 148 份表单的真实状态。delete 分支下这句指的是
|
// 「列表一个字段都没勾」(D6 分流,见上),非 delete 分支指的是表单勾选。
|
// 标题回落到当前业务记录 f_id;留痕以便区别于「配了但渲染不出来」。
|
extra.put("titleScope", "unconfigured");
|
if (baseRecordTitle != null) {
|
extra.put("titleFallback", "f_id");
|
}
|
}
|
if (!title.subTitles().isEmpty()) {
|
extra.put("recordSubTitles", title.subTitles());
|
if (title.subTable() != null) {
|
extra.put("recordSubTitleTable", title.subTable());
|
}
|
}
|
if (title.subDataUnavailable()) {
|
extra.put("subTitleSource", "unavailable");
|
}
|
// 子表标题完整性(2026-08-05 Codex 复审 Low)。两道上限一直是**悄悄**截的,读的人
|
// 看到 200 条会以为那就是全部;资源上界下推之后又多一种截法——额度耗尽时后面整张
|
// 子表都不碰,连 subDataUnavailable 都测不出来。缺席 = 完整,不缺席就说清是哪种截断。
|
if (title.subScope() != null) {
|
extra.put("subTitleScope", title.subScope());
|
}
|
|
// D3:批量删除标题缀「共 N 条」。批量操作物理合并后,主行只显示其中一条的标题——
|
// 不缀条数,用户看不出这一行代表的是 1 条还是 50 条。N 来自 Task 1 已加的
|
// VisualLogForm.batchSize(每条事件都带同一个值),审计侧直接读——batchSize 变量已在
|
// 前面的动作语义覆盖块之前读取(frontendBizData 膨胀防护也要用),这里直接复用。
|
String recordTitle = baseRecordTitle;
|
if (batchSize != null && batchSize > 1) {
|
extra.put("batchSize", batchSize);
|
if (recordTitle != null && !recordTitle.isEmpty()) {
|
recordTitle = recordTitle + "(共 " + batchSize + " 条)";
|
}
|
}
|
|
String bizCode = bizCode(snapshotRow, meta);
|
String bizModule = meta.getBizModule() != null ? meta.getBizModule() : ctx.appCode;
|
if (bizModule.equals(ctx.appCode)) {
|
String visualDevFullName = registry.resolveFullName(modelId);
|
if (visualDevFullName != null) {
|
bizModule = visualDevFullName;
|
}
|
}
|
AuditEventDTO dto = AuditEventDTO.builder()
|
.clientEventId(EVENT_ID_PREFIX + event.getEventId())
|
// 没有 batchOpId(新增/普通修改)时 operationId 与 clientEventId 仍同值,
|
// 分组结果与改动前逐字节一致;批量删除时两者不同值,靠 operationId 把
|
// 一批 N 条事件在写入侧合并为一行,clientEventId 继续按来源事件唯一。
|
.operationId(operationId)
|
.eventTime(ctx.eventTime)
|
.tenantId(ctx.tenantId)
|
.operatorId(ctx.userId)
|
.operatorName(ctx.userName)
|
.appName(SOURCE_APP)
|
.bizModule(bizModule)
|
.ip(ctx.ip)
|
.browser(ctx.browser)
|
.os(ctx.os)
|
.requestUri(ctx.uri)
|
.eventType(AuditConsts.TYPE_DATA_CHANGE)
|
.actionCode(actionCode)
|
// actionLabel(boolean) 本身仍是二分支(新增/修改)。delete 优先级最高、恒显示
|
// 「删除」——不接受 meta_data 的 bizButtonLabel 覆盖(评审修复轮次 1,理由见
|
// 前面动作语义覆盖块的 🔴 注释②:业务开发者随手起的列表按钮文案不该冒充删除
|
// 这个硬事实)。非删除场景才轮到 bizButtonLabel 优先于 actionLabel(create)。
|
// 不拼模块名,见 actionLabel 的 javadoc。
|
.actionLabel(delete ? "删除"
|
: (bizButtonLabel != null ? bizButtonLabel : actionLabel(create)))
|
.sourceLayer(0)
|
.targetTable(table)
|
.targetId(dataId)
|
.bizType(meta.getBizType())
|
.bizCode(bizCode)
|
.fieldDiffs(fieldDiffs)
|
.dataSnapshot(snapshotRow == null ? null
|
: AuditFieldDiff.snapshot(snapshotRow, masked, labels))
|
.extra(JsonUtil.getObjectToString(extra))
|
.recordTitle(recordTitle)
|
.entryType(entryType)
|
.entryId(entryId)
|
.entryName(entryName)
|
.build();
|
auditEventService.saveIdempotent(dto);
|
|
// ── 签名事件(spec §6)─────────────────────────────────────────────────────
|
// **顺序不可调换**:业务事件先落库,签名事件才写。表单没保存成功 → 没有 MQ 事件
|
// → 这里根本不会执行 → 签名日志天然不产生。药厂要的「业务失败则签名也不记」
|
// 由链路结构保证,不靠事后补偿。
|
//
|
// 每个签名一条,clientEventId 带 -SIGN-<signId> 后缀(多签名场景互不覆盖),
|
// 靠 UNIQUE(client_event_id) 挡广播重复消费。
|
// 标题/入口/单号继承业务事件——即使单独按 eventType=E_SIGNATURE 查,
|
// 也看得到"签的是哪张单子"。
|
// 请求顶层与保存后的数据列经常携带同一个 signId。候选必须都保留到动作语义判定,
|
// 但签名事实只能输出一次;请求候选排在最前,因此重复时稳定保留请求来源的展示标签。
|
Set<String> emittedSignIds = new LinkedHashSet<>();
|
for (SignCandidate candidate : signCandidates) {
|
AuditSignEvidence evidence = evidences.get(candidate.signId);
|
if (evidence == null) {
|
continue;
|
}
|
if (!candidate.thisOperation) {
|
continue; // 历史签名不在这里重发,已记进 extra.historicalSignRefs
|
}
|
if (!emittedSignIds.add(candidate.signId)) {
|
continue;
|
}
|
Map<String, Object> signExtra = new LinkedHashMap<>();
|
signExtra.put("signField", candidate.signField);
|
signExtra.put("sourceModelId", modelId);
|
AuditSignMetaData signMetaData = AuditSignMetaData.parse(evidence.metaData());
|
boolean reviewSignature = signMetaData.isReviewButton();
|
signExtra.put("signatureRole", reviewSignature ? "REVIEW" : "PRIMARY");
|
if (evidence.accountName() != null) {
|
signExtra.put("accountName", evidence.accountName());
|
}
|
if (evidence.opType() != null) {
|
signExtra.put("opType", evidence.opType());
|
}
|
AuditEventDTO signDto = AuditEventDTO.builder()
|
.clientEventId(operationId + "-SIGN-" + evidence.signId())
|
.operationId(operationId)
|
// 签名时刻取签名凭证自身的创建时刻,而不是本次表单保存时刻——
|
// 两者之间隔着用户填表的时间,混用会让时间线错位。
|
.eventTime(evidence.signedAt() != null ? evidence.signedAt() : ctx.eventTime)
|
.tenantId(ctx.tenantId)
|
.operatorId(evidence.operatorId() != null ? evidence.operatorId() : ctx.userId)
|
.operatorName(ctx.userName)
|
.appName(SOURCE_APP)
|
.bizModule(bizModule)
|
.ip(ctx.ip)
|
.browser(ctx.browser)
|
.os(ctx.os)
|
.requestUri(ctx.uri)
|
.eventType(AuditConsts.TYPE_E_SIGNATURE)
|
// 与 A1-b 历史反查同一组常量,别退回字面量(见 SIGN_ACTION_CODE 的 javadoc)
|
.actionCode(SIGN_ACTION_CODE)
|
.actionLabel(reviewSignature ? "复审签名" : "电子签名")
|
.sourceLayer(SIGN_SOURCE_LAYER)
|
// 与 A1-b 历史反查同一个常量,别退回字面量(见 SIGN_TABLE 的 javadoc)
|
.targetTable(AuditSignEvidenceReader.SIGN_TABLE)
|
.targetId(evidence.signId())
|
.bizType(meta.getBizType())
|
.bizCode(bizCode)
|
.reason(evidence.note())
|
.extra(JsonUtil.getObjectToString(signExtra))
|
.recordTitle(baseRecordTitle)
|
.entryType(entryType)
|
.entryId(entryId)
|
.entryName(entryName)
|
.build();
|
auditEventService.saveIdempotent(signDto);
|
}
|
}
|
|
/** READ 不保存查询参数、返回数据或字段快照,只记录访问事实。 */
|
private void recordRead(ProjectEventInstance event, Map<String, Object> form,
|
AuditFormRegistry.FormMeta meta, String table, String dataId,
|
AuditCtx ctx, Map<String, Object> extra,
|
String entryType, String entryId, String entryName) {
|
String readScope = str(form.get("readScope"));
|
if (readScope == null) {
|
readScope = dataId == null ? "LIST" : "DETAIL";
|
}
|
extra.put("readScope", readScope);
|
String eventId = EVENT_ID_PREFIX + event.getEventId();
|
AuditEventDTO dto = AuditEventDTO.builder()
|
.clientEventId(eventId)
|
.operationId(eventId)
|
.eventTime(ctx.eventTime)
|
.tenantId(ctx.tenantId)
|
.operatorId(ctx.userId)
|
.operatorName(ctx.userName)
|
.appName(SOURCE_APP)
|
.bizModule(meta.getBizModule() != null ? meta.getBizModule() : ctx.appCode)
|
.ip(ctx.ip)
|
.browser(ctx.browser)
|
.os(ctx.os)
|
.requestUri(ctx.uri)
|
.eventType(AuditConsts.TYPE_BIZ_ACTION)
|
.actionCode(AuditConsts.ACT_READ)
|
.actionLabel("DETAIL".equalsIgnoreCase(readScope) ? "查看详情" : "查询列表")
|
.sourceLayer(0)
|
.targetTable(table)
|
.targetId(dataId)
|
.bizType(meta.getBizType())
|
.extra(JsonUtil.getObjectToString(extra))
|
.entryType(entryType)
|
.entryId(entryId)
|
.entryName(entryName)
|
.build();
|
auditEventService.saveIdempotent(dto);
|
}
|
|
/**
|
* 这份签名候选是不是「本次操作」签的。三条互斥的分支,顺序即优先级:
|
*
|
* <ol>
|
* <li><b>请求携带的恒为真</b>({@link SignCandidate#fromRequest}):它按定义就是本次操作的
|
* 凭证,不需要、也不能拿历史反查去否定它;</li>
|
* <li><b>删除场景的数据列恒为假</b>:被删数据里的签名列存的是历史,见下方长注释(P1b);</li>
|
* <li><b>非删除场景的数据列查 {@code recordedSignIds}</b>:这个 signId 已经被记过一条
|
* 签名事件(且不是本次 operation 记的)⇒ 历史。见 {@link #recordedSignIds}。</li>
|
* </ol>
|
*
|
* <p><b>非删除场景为什么不能像原先那样恒为真</b>(2026-08-03 Codex 终审 High③):实测
|
* 一次复核保存会生成两条签名(确定 + 复核),分别落进 {@code biz_sign} / {@code fuhe_sign};
|
* {@code biz_sign} 每次保存都刷新成新的确定签名,而 {@code fuhe_sign} 只在复核那次写——
|
* 之后每次普通保存都会把它重发一条签名事件,{@code event_time} 停在上次复核那一刻却挂在
|
* 这次保存的 {@code operation_id} 下。就是 L078 骂的时间倒流,换到了更新路径上。
|
*
|
* <p><b>删除场景只认请求携带的那一份</b>({@code fromRequest},Task 1 平台补丁
|
* 从请求体的 {@code biz_sign} 传下来)。被删数据里的 {@code biz_sign}/{@code fuhe_sign}
|
* 存的是这条记录<b>创建/编辑时</b>的历史签名,它们各自早在当时那次操作里记过一条签名事件了;
|
* 在删除事件上重发一遍会同时踩三个坑(2026-08-02 端到端实测,audit_events id 39/40/43-46):
|
* <ol>
|
* <li><b>时间倒流</b>:签名事件的 {@code event_time} 取签名凭证自身的创建时刻,于是
|
* 15:08 的一次删除产出了四条 {@code event_time} 停在 <b>7-31</b> 的「签名」事件,
|
* 挂在这次删除的 {@code operation_id} 下——写入侧合并成一行后,看起来就是
|
* 「这次删除时签了四个名」,而事实是一个都没签在这次删除上;</li>
|
* <li><b>批量放大</b>:批量删 N 条 × 每条 M 个历史签名列 = N×M 条签名事件,实测删 2 条
|
* 产出 4 条,本该只有用户真正签的那 1 条;</li>
|
* <li><b>动作语义取错</b>:动作语义只认第一份证据,历史签名的 {@code meta_data} 会把
|
* 当时那次操作的 {@code biz_button}(实测「确定」)和 {@code biz_data} 当成这次删除的
|
* 上下文写进 {@code extra}。</li>
|
* </ol>
|
*
|
* <p><b>为什么不干脆把它们从 {@code signCandidates} 里剔掉</b>:这个列表还兼着
|
* 「哪些字段不许进标题」(见 {@code titleExcluded})。命名规则命中的签名列有
|
* {@code masked} 兜底,但 D4 的配置来源({@code confirmBtnConfig.biz_sign_field})允许
|
* 任意字段名,剔掉就等于让一个签名 id 明文进标题——审计表 append-only 存十年,洗不掉。
|
* 所以只在「产出事件」这一侧收窄。
|
*
|
* <p><b>入参是 {@code fromRequest} 布尔量而不是字段名</b>(2026-08-05 Codex 复审 High①):
|
* 拿字段名与 {@link #REQUEST_SIGN_FIELD} 比对,等于让一个可碰撞的字符串来决定
|
* 「这份证据够不够格」——业务表真有同名列时判定就被劫持了。来源跟着候选走。
|
*/
|
private static boolean signedByThisOperation(boolean delete, boolean fromRequest,
|
String signId, Set<String> recordedSignIds) {
|
if (fromRequest) {
|
// 🔴 **审计层不做授权判定**(2026-08-05 定案)。这里一度收过一个 authorized 参数,
|
// 由「签名人 == 操作人」推出——那个判据不成立:verify_user 的多人会签本就要求
|
// 两者不同,而「合法会签」与「盗用 signId」在数据上不可区分(见授权循环处长注释)。
|
// 请求携带的凭证按定义就是本次操作提交的凭证,它是不是**该**被提交,
|
// 由平台层同步校验回答,不是这里。不一致的事实已在 extra.signerDiffersFromOperator
|
// 里如实标注——照出来,不拦下来。
|
// (凭证压根不存在/跨租户的那一类是**可判定**的,已在候选剔除那一步拦掉并留痕。)
|
return true;
|
}
|
if (delete) {
|
return false;
|
}
|
return recordedSignIds == null || !recordedSignIds.contains(signId);
|
}
|
|
/**
|
* 这批 signId 里哪些**已经被记过签名事件**(= 历史)。
|
*
|
* <p><b>判据为什么取自 audit_events</b>(用户 2026-08-03 拍板 D7):候选是不是「本次刚签的」,
|
* 唯一不可篡改的依据就是「审计表里记过没有」。{@code lims_sign.meta_data}(含
|
* {@code biz_data})整块由前端 POST、后端原样落库、零校验,拿它判定等于把「凭空产出一条
|
* 签名事件」的口子交给前端报文;而 {@code data_snapshot} 里签名列是 {@code ***},
|
* 拿 baseline 比对「签名列变没变」也永远比不出来。
|
*
|
* <p><b>为什么排除本次 operation_id</b>:MQ 重投递会让同一条事件被消费两次,批量场景下
|
* N 条事件还共享同一个 {@code batchOpId}。不排除的话,第一条事件写下的签名事件会让后面
|
* 那些「同一次操作」的事件把它当成历史——判定必须对「同一次操作」保持稳定。
|
*
|
* <p><b>自愈</b>:某条签名当年因 MQ/落库失败没记上,下一次操作会把它当「本次」补一条。
|
* 补上的那条 {@code event_time} 仍是签名凭证自身的时刻,但 {@code operation_id} 属于补记
|
* 的那次操作——这是可见的代价,好过永远查不到这条签名。
|
*
|
* @return 已记过的 signId 集合;<b>查询失败返回 {@code null}</b>——调用方据此走 D8 的
|
* fail-closed(全判历史 + {@code extra.signHistoryDegraded}),不要当成空集。
|
*/
|
private Set<String> recordedSignIds(Collection<String> signIds, String operationId,
|
String tenantId) {
|
try {
|
List<String> rows = auditEventMapper.selectRecordedSignIds(
|
AuditSignEvidenceReader.SIGN_TABLE, AuditConsts.TYPE_E_SIGNATURE,
|
SIGN_ACTION_CODE, SIGN_SOURCE_LAYER, signIds, operationId, tenantId);
|
Set<String> recorded = new LinkedHashSet<>();
|
if (rows != null) {
|
for (String id : rows) {
|
if (id != null && !id.trim().isEmpty()) {
|
recorded.add(id.trim());
|
}
|
}
|
}
|
return recorded;
|
} catch (Throwable t) {
|
log.warn("[AUDIT-L0] 历史签名反查失败,本次按 fail-closed 全判历史 count={} err={}",
|
signIds.size(), t.toString());
|
return null;
|
}
|
}
|
|
/**
|
* 一条历史签名的可追引用(A2,Codex 终审 Medium⑤)。
|
*
|
* <p><b>刻意不带本次的 operation/eventTime</b>:这是历史事实的引用,不是本次操作的证据
|
* (L078 的规矩)。{@code signedAtMillis} 取签名凭证自身的创建时刻(epoch 毫秒),
|
* 缺席表示凭证行读不到创建时刻,不是「没签过」。
|
*/
|
private static Map<String, Object> historicalSignRef(String signField, String signId,
|
AuditSignEvidence evidence) {
|
Map<String, Object> ref = new LinkedHashMap<>();
|
ref.put("signField", signField);
|
ref.put("signId", signId);
|
if (evidence != null && evidence.signedAt() != null) {
|
ref.put("signedAtMillis", evidence.signedAt().getTime());
|
}
|
return ref;
|
}
|
|
/**
|
* CREATE 时应当以「已知为空」显式写进 baseline 的字段(小写、稳定顺序)。
|
*
|
* <p><b>要解决的事实</b>(2026-07-30 联调报障,本机实测复现):在线表单保存时,平台会把值为空
|
* 的字段整个从 {@code newData} 里剥掉——{@code lims_qingyandan} 的 CREATE 事件快照只有 21 键,
|
* 而已发布表单模型有 23 个字段,少的正是建单时留空的那几个。结果是这些字段**第一次被填写**
|
* 时,UPDATE 分支的两侧交集({@code onlyFields} 互取对方 keySet)把它们整个排除,
|
* {@code field_diffs} 漏记一次真实变更;它们进过一次快照之后反而正常了。
|
*
|
* <p><b>补的是"已知为空",不是"猜一个旧值"</b>。判据来自
|
* {@link AuditFormRegistry#resolveBaselineFields}:该字段属于**这张已发布表单**、且确是主表
|
* 一列。建单提交的就是整份表单,没被携带即等于留空——这是有据可依的推断,而不是对
|
* 数据库写入前快照里的未知字段瞎填。该补齐只用于 CREATE 的操作后快照,UPDATE 差异
|
* 始终直接使用数据库写入前后的实际查询结果。
|
*
|
* <h3>四类字段不补,每一类都有代价对应</h3>
|
* <ul>
|
* <li><b>本次已携带</b>:{@code eventRow} 里有的值优先,补了只会造出大小写重复键。</li>
|
* <li><b>平台技术列</b>:{@code forDiff} 本就要剔,补进来纯噪音。</li>
|
* <li><b>脱敏列 / 签名控件</b>:层 0 判定不了它们的变更(快照里存的是占位符),
|
* 补一个"已知为空"等于宣称"初始密码是第一次设置"——这正是 B5 评审 M-3 不给的那半格。</li>
|
* <li><b>子表字段</b>:baseline 只存主表快照,子表明细本层给不出(见
|
* {@link AuditFieldDiff.FieldMeta} 类注释),补空会把"判不了"伪装成"本来就没有"。</li>
|
* </ul>
|
*/
|
static List<String> knownEmptyFields(Map<String, Object> eventRow,
|
Set<String> formFields,
|
Set<String> masked,
|
Map<String, AuditFieldDiff.FieldMeta> labels) {
|
if (formFields == null || formFields.isEmpty()) {
|
return Collections.emptyList();
|
}
|
Set<String> carried = new java.util.HashSet<>();
|
if (eventRow != null) {
|
for (String key : eventRow.keySet()) {
|
if (key != null) {
|
carried.add(key.toLowerCase(Locale.ROOT));
|
}
|
}
|
}
|
List<String> known = new ArrayList<>();
|
for (String field : formFields) {
|
if (field == null) {
|
continue;
|
}
|
String lower = field.toLowerCase(Locale.ROOT);
|
if (carried.contains(lower) || isTechColumn(lower) || isMasked(lower, masked)
|
|| isCredentialReference(lower)) {
|
continue;
|
}
|
AuditFieldDiff.FieldMeta fm = labels == null ? null : labels.get(lower);
|
if (AuditFieldDiff.isCredentialMetadata(fm)) {
|
continue;
|
}
|
if (fm != null && AuditFieldDiff.JNPF_KEY_TABLE.equals(fm.jnpfKey())) {
|
continue;
|
}
|
known.add(lower);
|
}
|
return known;
|
}
|
|
/**
|
* 把「已知为空」显式写进 CREATE 快照;事件明确携带的值恒优先。
|
*
|
* <p>用空串而不是 {@code null}:{@code data_snapshot} 落库前会过
|
* {@link AuditFieldDiff#snapshot}({@code stringify(null)} 同样得空串),两者存进去一模一样,
|
* 但空串让人直接读 JSON 时看得出"这个字段当时就是空的",而不是像个漏写的键。
|
* 比较侧不受影响:{@link AuditFieldDiff#valuesEqual} 把 null 与空串一律视作空。
|
*/
|
static Map<String, Object> withKnownEmpty(Map<String, Object> eventRow, List<String> knownEmpty) {
|
if (knownEmpty == null || knownEmpty.isEmpty()) {
|
return eventRow;
|
}
|
Map<String, Object> row = new LinkedHashMap<>();
|
for (String field : knownEmpty) {
|
row.put(field, "");
|
}
|
if (eventRow != null) {
|
row.putAll(eventRow);
|
}
|
return row;
|
}
|
|
/**
|
* 从事件的 {@code listLog} 里提取「字段名 → 展示元数据」。
|
*
|
* <p>这是层 0 相对层 1 白捡的一块元数据:listLog 由 visualdev 按**在线表单模型**生成,
|
* 随表单发布自动更新,不需要任何手写映射表({@code AuditFieldDiff} 类注释里说的
|
* 「正确的来源是元数据」)。
|
*
|
* <p><b>只取元数据、不取它的 oldData/newData</b>——那两个是 visualdev 用不可信的
|
* oldData 算出来的**值**(L003),值一律以 PG baseline 为准。
|
* 取的三个({@code fieldName}/{@code jnpfKey}/{@code nameModified})都是**字段的属性**
|
* 而不是这次改了什么,故不受 L003 约束。
|
*
|
* <p>{@code jnpfKey}/{@code nameModified} 是 D1 补的(见 {@link AuditFieldDiff.FieldMeta}):
|
* 它们各自对应前端一条渲染分支,缺了 {@code nameModified} 会把字典字段的存储原值当明文渲染。
|
*/
|
private Map<String, AuditFieldDiff.FieldMeta> fieldMeta(Object listLog) {
|
if (!(listLog instanceof List)) {
|
return null;
|
}
|
Map<String, AuditFieldDiff.FieldMeta> meta = new LinkedHashMap<>();
|
for (Object item : (List<?>) listLog) {
|
Map<String, Object> row = asMap(item);
|
if (row == null) {
|
continue;
|
}
|
String field = str(row.get("field"));
|
if (field == null) {
|
continue;
|
}
|
// nameModified 在平台 VisualLogModel 上是 boolean 基本类型,跨 JVM 反序列化后
|
// 按 Map 取(L004)可能是 Boolean 也可能是字符串 "true",两种都认;认不出就传 null
|
// (= 该键不出现 = 前端走明文分支),**不猜默认值**——猜错的方向是「把该藏的值露出来」。
|
Object nm = row.get("nameModified");
|
Boolean nameModified = null;
|
if (nm instanceof Boolean) {
|
nameModified = (Boolean) nm;
|
} else if (nm != null) {
|
String s = String.valueOf(nm).trim();
|
if ("true".equalsIgnoreCase(s)) {
|
nameModified = Boolean.TRUE;
|
} else if ("false".equalsIgnoreCase(s)) {
|
nameModified = Boolean.FALSE;
|
}
|
}
|
meta.put(field, new AuditFieldDiff.FieldMeta(str(row.get("fieldName")),
|
str(row.get("jnpfKey")), nameModified));
|
}
|
return meta.isEmpty() ? null : meta;
|
}
|
|
/** 从平台原生 listLog 中提取已由数据库前后快照计算完成的子表行级变化。 */
|
@SuppressWarnings("unchecked")
|
static List<Map<String, Object>> childDiffs(Object listLog) {
|
if (!(listLog instanceof List)) {
|
return Collections.emptyList();
|
}
|
List<Map<String, Object>> result = new ArrayList<>();
|
for (Object raw : (List<?>) listLog) {
|
if (!(raw instanceof Map)) {
|
continue;
|
}
|
Map<String, Object> row = (Map<String, Object>) raw;
|
if (str(row.get("field")) == null
|
|| !AuditFieldDiff.JNPF_KEY_TABLE.equals(str(row.get("jnpfKey")))
|
|| !(row.get("chidData") instanceof List)
|
|| ((List<?>) row.get("chidData")).isEmpty()
|
|| !(row.get("chidField") instanceof List)
|
|| ((List<?>) row.get("chidField")).isEmpty()) {
|
continue;
|
}
|
Map<String, Object> child = new LinkedHashMap<>();
|
child.put("field", row.get("field"));
|
child.put("fieldName", row.get("fieldName"));
|
child.put("jnpfKey", AuditFieldDiff.JNPF_KEY_TABLE);
|
child.put("componentType", AuditFieldDiff.JNPF_KEY_TABLE);
|
child.put("targetTable", row.get("targetTable"));
|
child.put("type", row.get("type") == null ? 1 : row.get("type"));
|
child.put("nameModified", false);
|
child.put("chidData", row.get("chidData"));
|
child.put("chidField", row.get("chidField"));
|
result.add(child);
|
}
|
return result;
|
}
|
|
@SuppressWarnings("unchecked")
|
static String appendChildDiffs(String fieldDiffs, List<Map<String, Object>> childDiffs) {
|
if (childDiffs == null || childDiffs.isEmpty()) {
|
return fieldDiffs;
|
}
|
List<Object> merged = new ArrayList<>();
|
if (fieldDiffs != null && !fieldDiffs.trim().isEmpty()) {
|
try {
|
Object parsed = JSON.parse(fieldDiffs);
|
if (parsed instanceof List) {
|
merged.addAll((List<Object>) parsed);
|
}
|
} catch (Throwable ignored) {
|
// 普通字段 JSON 损坏时仍保留可信的子表变化。
|
}
|
}
|
merged.addAll(childDiffs);
|
return JSON.toJSONString(merged);
|
}
|
|
static Map<String, Object> withoutFields(Map<String, Object> row, Set<String> fields) {
|
if (row == null || row.isEmpty() || fields == null || fields.isEmpty()) {
|
return row;
|
}
|
Set<String> normalized = new LinkedHashSet<>();
|
for (String field : fields) {
|
if (field != null) {
|
normalized.add(field.toLowerCase(Locale.ROOT));
|
}
|
}
|
Map<String, Object> result = new LinkedHashMap<>();
|
for (Map.Entry<String, Object> entry : row.entrySet()) {
|
if (entry.getKey() == null || !normalized.contains(entry.getKey().toLowerCase(Locale.ROOT))) {
|
result.put(entry.getKey(), entry.getValue());
|
}
|
}
|
return result;
|
}
|
|
/**
|
* 本次变更里命中「子表」控件的字段名。
|
*
|
* <p>标准在线表单事件通过 listLog 携带平台按数据库前后数据算出的 {@code chidData};
|
* 只有补丁上线前的旧事件或非标准发布方缺少 listLog 时,层 0 才无法还原子表行级明细。
|
* 此时把「判不了」如实写进 extra,避免把空明细误解成「子表没有修改」。
|
*/
|
private List<String> subTableFields(Map<String, AuditFieldDiff.FieldMeta> meta) {
|
if (meta == null) {
|
return java.util.Collections.emptyList();
|
}
|
List<String> hit = new ArrayList<>();
|
for (Map.Entry<String, AuditFieldDiff.FieldMeta> e : meta.entrySet()) {
|
if (AuditFieldDiff.JNPF_KEY_TABLE.equals(e.getValue().jnpfKey())) {
|
hit.add(e.getKey());
|
}
|
}
|
return hit;
|
}
|
|
/**
|
* 业务单号。<b>取不到不丢事件</b>(与旧 listener 的关键差异,见类注释)。
|
* 未配置取值字段时直接留空——大量表单本就没有业务单号这一说。
|
*/
|
private String bizCode(Map<String, Object> newData, AuditFormRegistry.FormMeta meta) {
|
String field = meta.getBizCodeField();
|
if (field == null || field.isEmpty() || newData == null) {
|
return null;
|
}
|
return str(newData.get(field));
|
}
|
|
/**
|
* 层 0 的动作名只给<b>纯动作词</b>。
|
*
|
* <p>模块语义已经由 {@code entry_name}(用户点进来的菜单)承担,再把它拼进动作名
|
* 就成了「LIMS-请验单 新建」这种既不是模块也不是动作的混合物。
|
* 层 2 事件(列表按钮)仍用各自的 {@code actionLabel}——那里的动作词是业务自定义的。
|
*/
|
private String actionLabel(boolean create) {
|
return create ? "新增" : "修改";
|
}
|
|
/**
|
* diff 的输入预处理:剔除平台技术列。脱敏列保留到比较阶段,确认变化后再隐藏值。
|
*
|
* <p><b>为什么合成一个方法而不是在调用处各套两层</b>:UPDATE 路径的 beforeRow 与 afterRow
|
* 必须经**完全相同**的剔除规则,否则单边少剔一列就会产出一条假 diff(该列在一侧存在、
|
* 另一侧不存在)。把"两侧同规则"这件事交给代码结构保证,而不是靠调用处的注释约定
|
* ——约定会在下一次改动里悄悄失效(同 L066)。
|
*
|
* <p><b>只用于 diff,不用于 {@code data_snapshot}</b>:快照保留完整行(含技术列),
|
* 剔除只发生在“要不要给人看这条变更”这一层。
|
*
|
* <p>{@code static} + 包内可见是为了让 {@code AuditL0PartialSnapshotProbe} 直接驱动它:
|
* “技术列被剔、脱敏列只隐藏值”这条口径的错法是静默地多出、少掉或泄露一列。
|
*/
|
static Map<String, Object> forDiff(Map<String, Object> row) {
|
return withoutTechColumns(row);
|
}
|
|
/** 比较同一次数据库写入前后的两份快照;单侧缺失字段按空值参与比较。 */
|
static String databaseBoundaryDiff(Map<String, Object> beforeRow,
|
Map<String, Object> afterRow,
|
Set<String> masked,
|
Map<String, AuditFieldDiff.FieldMeta> labels,
|
Set<String> childFields) {
|
Map<String, Object> before = withoutFields(forDiff(beforeRow), childFields);
|
Map<String, Object> after = withoutFields(forDiff(afterRow), childFields);
|
return AuditFieldDiff.diff(before, after, masked, labels);
|
}
|
|
/**
|
* 剔除平台技术列后的副本。
|
*
|
* <p>判据默认是**前缀 {@code f_}**,依据是仓库 CLAUDE.md「Conventions」里的业务规范:
|
* 业务字段禁止以 {@code f_} 开头,该前缀为平台技术列保留。全库实查坐实(2026-07-27):
|
* lims 的 65 张表里带该前缀的列共 15 个,全部是平台列({@code f_id} / {@code f_tenant_id} /
|
* {@code f_version} / {@code f_delete_mark} / {@code f_creator_time} / {@code f_flow_state} /
|
* {@code f_foreign_id} 等),无一例业务字段。
|
* 其中创建人、创建时间、最后修改人、最后修改时间是审计需要展示的操作责任信息,作为明确例外保留。
|
*
|
* <p>顺带剔除裸 {@code id}:前端提交时把主键同时放在 {@code f_id} 和 {@code id} 两个键上,
|
* 后者是别名、语义重复。
|
*
|
* <p><b>为什么要剔</b>:CREATE 路径的 before 为 null,于是**每一列都算新增**,
|
* 技术列会混进 field_diffs 变成噪音——{@code f_id} 与 {@code target_id} 列完全重复、
|
* {@code f_version} 恒为 0。旧 lims listener 按 {@code @FieldLabel} 只记业务字段,
|
* 本方法把这个口径补回来(2026-07-27,用户拍板"做干净点")。
|
*/
|
private static Map<String, Object> withoutTechColumns(Map<String, Object> row) {
|
if (row == null || row.isEmpty()) {
|
return row;
|
}
|
Map<String, Object> out = new LinkedHashMap<>(row.size());
|
for (Map.Entry<String, Object> entry : row.entrySet()) {
|
if (!isTechColumn(entry.getKey())) {
|
out.put(entry.getKey(), entry.getValue());
|
}
|
}
|
return out;
|
}
|
|
/** 与 {@link #isMasked} 同口径按小写比,避免大小写混写的列名漏判。 */
|
private static boolean isTechColumn(String column) {
|
if (column == null) {
|
return false;
|
}
|
String lower = column.toLowerCase(Locale.ROOT);
|
return (lower.startsWith(TECH_COLUMN_PREFIX) && !AUDITED_PLATFORM_COLUMNS.contains(lower))
|
|| "id".equals(lower);
|
}
|
|
/** 与 {@code AuditFieldDiff} 同口径:脱敏清单是小写的,列名按小写比。 */
|
private static boolean isMasked(String column, Set<String> masked) {
|
return column != null && masked.contains(column.toLowerCase(Locale.ROOT));
|
}
|
|
/**
|
* 签名凭证引用属于内建敏感字段,不依赖 Nacos 的 maskedFields 是否漏配。
|
*/
|
private static Set<String> effectiveMasked(Set<String> configured, Map<String, Object> row,
|
Map<String, AuditFieldDiff.FieldMeta> metadata) {
|
Set<String> result = new java.util.LinkedHashSet<>();
|
if (configured != null) {
|
result.addAll(configured);
|
}
|
if (row != null) {
|
for (String field : row.keySet()) {
|
if (isCredentialReference(field)) {
|
result.add(field.toLowerCase(Locale.ROOT));
|
}
|
}
|
}
|
if (metadata != null) {
|
for (Map.Entry<String, AuditFieldDiff.FieldMeta> entry : metadata.entrySet()) {
|
if (entry.getKey() != null && AuditFieldDiff.isCredentialMetadata(entry.getValue())) {
|
result.add(entry.getKey().toLowerCase(Locale.ROOT));
|
}
|
}
|
}
|
return result;
|
}
|
|
public static boolean isCredentialReference(String field) {
|
if (field == null) {
|
return false;
|
}
|
String lower = field.trim().toLowerCase(Locale.ROOT);
|
return "biz_sign".equals(lower)
|
|| "bizsign".equals(lower)
|
|| "sign_id".equals(lower)
|
|| "signid".equals(lower)
|
|| lower.endsWith("_sign")
|
|| field.endsWith("Sign")
|
|| field.endsWith("SignId");
|
}
|
|
/** 按小写字段名从事件行取值,兼容大小写混写的列名。 */
|
private static Object lookup(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;
|
}
|
|
/**
|
* 把事件跨 JVM 序列化后的值统一成展示用字符串。写入前后的两侧应用完全相同的规则,
|
* 避免 JSON 数值、日期等运行时类型差异产生假 diff。用
|
* {@link AuditFieldDiff#stringify} 保证最终展示值与层 1 的字段差异契约一致。
|
*/
|
private Map<String, Object> stringifyValues(Map<String, Object> row) {
|
if (row == null) {
|
return null;
|
}
|
Map<String, Object> out = new LinkedHashMap<>(row.size());
|
for (Map.Entry<String, Object> entry : row.entrySet()) {
|
out.put(entry.getKey(), AuditFieldDiff.stringify(entry.getValue()));
|
}
|
return out;
|
}
|
|
/**
|
* 事件 source 统一成 Map(L004)。跨 JVM 来的是 {@code JSONObject}(本身就是 Map);
|
* 同 JVM 本地事件是 {@code VisualLogForm},走 {@code JSON.toJSON} 转过去。
|
*/
|
@SuppressWarnings("unchecked")
|
private Map<String, Object> asMap(Object source) {
|
if (source == null) {
|
return null;
|
}
|
if (source instanceof Map) {
|
return (Map<String, Object>) source;
|
}
|
Object json = JSON.toJSON(source);
|
return json instanceof Map ? (Map<String, Object>) json : null;
|
}
|
|
private static String str(Object value) {
|
if (value == null) {
|
return null;
|
}
|
String s = value.toString().trim();
|
return s.isEmpty() ? null : s;
|
}
|
|
/**
|
* 客户端显示值只能补充标题解析,不能决定标题字段。这里按发布表单主字段白名单、
|
* 脱敏/签名排除集和固定长度上限收口,非法或多余字段直接丢弃。
|
*/
|
private static Map<String, String> frontendDisplayValues(Object raw,
|
AuditTitleSpec spec,
|
Set<String> excludedFields) {
|
if (!(raw instanceof Collection) || spec == null || spec.mainFields().isEmpty()) {
|
return Collections.emptyMap();
|
}
|
Set<String> allowed = new LinkedHashSet<String>(spec.mainFields());
|
Map<String, String> result = new LinkedHashMap<String, String>();
|
int scanned = 0;
|
for (Object item : (Collection<?>) raw) {
|
if (++scanned > 50) {
|
break;
|
}
|
if (!(item instanceof Map)) {
|
continue;
|
}
|
Map<?, ?> value = (Map<?, ?>) item;
|
String field = str(value.get("field"));
|
String display = str(value.get("displayValue"));
|
if (field == null || display == null) {
|
continue;
|
}
|
String normalized = field.toLowerCase(Locale.ROOT);
|
if (!allowed.contains(normalized) || containsIgnoreCase(excludedFields, normalized)) {
|
continue;
|
}
|
display = display.replace('\r', ' ').replace('\n', ' ').trim();
|
if (!display.isEmpty()) {
|
result.put(normalized, display.length() <= 400 ? display : display.substring(0, 400));
|
}
|
}
|
return result;
|
}
|
|
private static boolean containsIgnoreCase(Set<String> values, String expected) {
|
if (values == null) {
|
return false;
|
}
|
for (String value : values) {
|
if (value != null && expected.equals(value.toLowerCase(Locale.ROOT))) {
|
return true;
|
}
|
}
|
return false;
|
}
|
|
/**
|
* 一份签名候选:<b>值</b>({@code signId})、<b>来源</b>({@code fromRequest})、
|
* <b>判定结果</b>({@code thisOperation})三者绑在一起走完全程。
|
*
|
* <p><b>为什么不是 {@code Map<String,String> signFieldToId}</b>(2026-08-05 Codex 复审 High①):
|
* 那个结构把「来源」编码进了 map 的 <b>key</b>(伪字段名 {@link #REQUEST_SIGN_FIELD}),
|
* 于是一个可碰撞的字符串同时承担了标识与授权两件事——业务表只要有同名列,数据列证据
|
* 就会被当成请求来源改写 {@code action_code},且 {@code putAll} 会用数据列的值覆盖请求值。
|
* 判定结果同理:原先记在一个「字段名集合」里,也是按名字装。名字不是身份,
|
* 来源与判定都必须是候选自己的属性(L081:来源是属性,不能从内容反推)。
|
*
|
* <p>{@code signField} 只剩一个用途:落进 {@code signExtra.signField} / 历史引用给人看,
|
* 以及当「哪些列不许进标题」的名单。任何判定都不许再读它。
|
*/
|
private static final class SignCandidate {
|
/** 数据列名;请求携带的那份是展示标签 {@link #REQUEST_SIGN_FIELD}。 */
|
private final String signField;
|
private final String signId;
|
/** 是否来自请求顶层的 {@code bizSign}(Task 1 平台补丁)——唯一够格改写动作语义的来源。 */
|
private final boolean fromRequest;
|
/** 是不是「本次操作」签的,由 {@link #signedByThisOperation} 一处判定、下游共用。 */
|
private boolean thisOperation;
|
|
// 【曾经有过一个 authReject 字段,2026-08-05 当天撤掉了】——它给「签名人≠操作人」
|
// 当拒收判据,而那个判据不成立(verify_user 会签合法且不可区分,见授权循环处的长注释)。
|
// 撤掉而不是留着不用:死字段会让下一个人以为审计层做了授权判定,而它没有、也做不了。
|
|
private SignCandidate(String signField, String signId, boolean fromRequest) {
|
this.signField = signField;
|
this.signId = signId;
|
this.fromRequest = fromRequest;
|
}
|
}
|
|
/**
|
* AUDIT-P1 注入的请求上下文(平台补丁 #5,登记于 tasks/jnpf-platform-patches.md)。
|
*
|
* <p>发布方把它序列化成 JSON **字符串**挂在 form.auditCtx 上({@code ProjectEventBuilder}
|
* 没有任何 extras 通道,只能挂在 form 自己的新增字段里)。取不到时走降级路径并**留痕**——
|
* 静默降级等于「审计有洞而无人知道」。
|
*/
|
private static final class AuditCtx {
|
private String userId = UNKNOWN;
|
private String userName;
|
private String tenantId = AuditApiConsts.DEFAULT_TENANT_ID;
|
private String ip;
|
private String browser;
|
private String os;
|
private String uri;
|
/** 前端路由识别出的应用编码与运行范围(FRONTEND/BACKEND/CONSOLE)。 */
|
private String appCode;
|
private String appScope;
|
/** 用户点进来的菜单 id(AUDIT-P1 从 {@code Jnpf-Menu-Id} 请求头透传)。没带就是 null。 */
|
private String menuId;
|
/** 请求线程固化的 X-Audit-Biz-Sign;用于跨 HTTP/MQ 链路关联同一次前端操作。 */
|
private String auditBizSign;
|
private Date eventTime;
|
|
/**
|
* @param eventTenantId {@code ProjectEvent.tenantId}——事件框架原生的租户通道。
|
* 计划 §Task C1 与 spec §5.4 指定的就是它;auditCtx 是 AUDIT-P1
|
* 后来加的、信息更全的通道,故按「auditCtx → 事件原生 → 哨兵」回落。
|
* 单租户下两条都是空串、结果都回落 "0",差别只在多租户 + auditCtx
|
* 缺席(补丁未部署 / 在途旧消息)时——那时事件原生通道能救回真租户,
|
* 少了它就会把真租户写成 "0"(评审 I-4)。
|
*/
|
static AuditCtx parse(Object raw, String eventTenantId, Map<String, Object> extra) {
|
AuditCtx ctx = new AuditCtx();
|
Map<String, Object> src = null;
|
try {
|
if (raw instanceof Map) {
|
src = castMap(raw);
|
} else if (raw != null) {
|
String json = raw.toString().trim();
|
if (!json.isEmpty()) {
|
src = JsonUtil.stringToMap(json);
|
}
|
}
|
} catch (Throwable t) {
|
src = null; // 解析不了就按「没有上下文」处理,下面统一走降级
|
}
|
if (src == null || src.isEmpty()) {
|
// 补丁未部署 / 在途的旧消息 / 非 Web 线程触发的保存(定时任务等)
|
extra.put("whoSource", "degraded");
|
extra.put("timeSource", "degraded");
|
ctx.eventTime = new Date();
|
ctx.applyTenant(null, eventTenantId);
|
return ctx;
|
}
|
String userId = str(src.get("userId"));
|
if (userId == null) {
|
extra.put("whoSource", "degraded");
|
} else {
|
ctx.userId = userId;
|
}
|
ctx.userName = str(src.get("userName"));
|
ctx.applyTenant(str(src.get("tenantId")), eventTenantId);
|
ctx.ip = str(src.get("ip"));
|
ctx.uri = str(src.get("uri"));
|
ctx.appCode = str(src.get("appCode"));
|
ctx.appScope = str(src.get("appScope"));
|
ctx.menuId = str(src.get("menuId"));
|
ctx.auditBizSign = str(src.get("auditBizSign"));
|
ctx.parseUserAgent(str(src.get("userAgent")));
|
// 操作时刻取自发布方(请求线程),而不是这里的消费时刻——两者之间隔着 MQ 投递,
|
// 积压时能差出好几分钟,那会让审计时间线整体错位。
|
Object opTime = src.get("opTime");
|
if (opTime instanceof Number) {
|
ctx.eventTime = new Date(((Number) opTime).longValue());
|
} else {
|
extra.put("timeSource", "degraded");
|
ctx.eventTime = new Date();
|
}
|
return ctx;
|
}
|
|
/** 租户回落链:auditCtx.tenantId → ProjectEvent.tenantId → 哨兵(见 {@link #parse} 注释)。 */
|
private void applyTenant(String fromAuditCtx, String fromEvent) {
|
if (fromAuditCtx != null) {
|
this.tenantId = fromAuditCtx;
|
} else if (str(fromEvent) != null) {
|
this.tenantId = str(fromEvent);
|
}
|
// 两者都空白时保持字段初始值 AuditApiConsts.DEFAULT_TENANT_ID
|
}
|
|
private void parseUserAgent(String ua) {
|
if (ua == null) {
|
return;
|
}
|
try {
|
UserAgent agent = UserAgentUtil.parse(ua);
|
if (agent != null) {
|
this.browser = join(agent.getBrowser().getName(), agent.getVersion());
|
this.os = join(agent.getPlatform().getName(), agent.getOsVersion());
|
}
|
} catch (Throwable ignore) {
|
// UA 解析纯属锦上添花,失败不影响事件
|
}
|
}
|
|
@SuppressWarnings("unchecked")
|
private static Map<String, Object> castMap(Object raw) {
|
return (Map<String, Object>) raw;
|
}
|
|
private static String join(String a, String b) {
|
String x = a == null ? "" : a.trim();
|
String y = b == null ? "" : b.trim();
|
String r = (x + " " + y).trim();
|
return r.isEmpty() ? null : r;
|
}
|
}
|
}
|