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.support.AuditHiddenFieldDiffFilter; 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)。 * *

jnpf-visualdev 在 {@code VisualLogServiceImpl.createEventLog} 以 BROADCASTING 发布 * {@code VSLOG_EVENT_KEY} 事件(平台补丁 #1),经 RocketMQ 跨 JVM 到达本服务。本类是 lims 侧 * {@code BaseVisualLogListener} 的迁入版,与它同时订阅——存储天然隔离(新写 audit_events、 * 旧写 lims_biz_log),影子期两条链路互不干扰。 * *

* *

数据库写入边界快照

*

每条层 0 事件仍在 {@code data_snapshot} 保存操作后的数据,但 UPDATE 的 * {@code field_diffs} 直接比较事件中的 {@code oldData}/{@code newData}。字段只存在于新数据时, * 旧值按空处理并记录为新增;只存在于旧数据时,记录为清空。不再查询上一条审计日志作为基线。 * *

与旧 listener 的一处**刻意不同**(A/B 比对必须知道)

*

旧 listener 把 {@code jianyan_liushuihao} 当硬过滤器:取不到就整条丢弃。本实现不跟—— * bizCode 取不到就留空,事件照记。理由是审计的红线是「这次改过不能缺」(B3 确立),而 * {@code jianyan_liushuihao} 是 LIMS 检验流水号,环境监测/水系统等模块的表单根本没有这个字段, * 按旧口径它们一条都不会被记。后果:同一批表单上 {@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"; /** * 故可安全按前缀剔除,不需要维护一张会漂移的列名白名单。详见 {@link #withoutTechColumns}。 */ private static final String TECH_COLUMN_PREFIX = "f_"; /** * 虽带平台前缀,但它们描述的是业务记录的创建/修改责任人和时间,属于审计事实。 * 这些字段必须进入 field_diffs,不能随其余平台技术列一起剔除。 */ private static final Set 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} 的展示标签。 * *

🔴 它只是标签,不是判据(2026-08-05 Codex 复审 High①)。此前它同时兼着 * 「来源标记」与 {@code Map signFieldToId} 的 key,于是「够不够格 * 改写 action_code」这件事被编码在了一个可碰撞的字符串里:业务表只要真有一个 * 同名列,它的数据列证据就会被当成请求来源({@code reviewFromRequest=true} → 把普通 * UPDATE 永久写成 REVIEW),而且 {@code reordered.putAll} 会让数据列的值覆盖 * 请求值。全库实查当前 0 个同名列,是埋着的陷阱不是活的故障——但 L081 的规矩正是 * 「来源必须单独记成标记跟着值走」,用字段名编码来源等于把标记又塞回了内容里。 * 现在来源是 {@link SignCandidate#fromRequest} 这个独立布尔量,本常量退回纯展示用途, * 值保持不变是为了历史数据的 {@code signExtra.signField} 语义连续。 */ private static final String REQUEST_SIGN_FIELD = "__request_biz_sign__"; /** * 本类产出的签名事件的 action_code 与 source_layer。 * *

抽成常量是刻意的(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)。**超出只留痕、不拒绝**。 * *

取 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); } } static void appendDmsWorkflowContext(Map extra, String table, String dataId, Map form) { // 仅 DMS 审核表消费保存端已验证的上下文,不在异步消费时查询当前节点。 if ("dms_wenjian_xinzeng_shenhe".equals(table) || "dms_wenjian_xiuding_shenhe".equals(table)) { Object raw = form.get("workflowAuditContext"); Map workflow = raw instanceof Map ? (Map) raw : null; if (workflow != null && dataId != null && dataId.equals(str(workflow.get("taskId"))) && str(workflow.get("operatorId")) != null) { Map frozen = new LinkedHashMap<>(); for (String key : Arrays.asList("taskId", "operatorId", "nodeId", "nodeCode", "nodeName")) { String value = str(workflow.get(key)); if (value != null) frozen.put(key, value); } extra.put("workflow", frozen); } } } private void handle(ProjectEventInstance event) { if (!registry.isEnabled() || event == null) { return; } Map 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 oldData = asMap(form.get("oldData")); Map newData = asMap(form.get("newData")); // 两侧统一字符串化,避免 JDBC/JSON 数值与日期类型差异制造假 diff。 Map beforeRow = stringifyValues(oldData); Map eventRow = stringifyValues(newData); Map labels = AuditFormRegistry.mergeFieldMetadata( registry.resolveFieldMetadata(modelId, table), fieldMeta(form.get("listLog"))); List> childDiffs = childDiffs(form.get("listLog")); Set childFields = new LinkedHashSet<>(subTableFields(labels)); Set masked = effectiveMasked(meta.getMaskedFields(), eventRow, labels); masked.addAll(effectiveMasked(meta.getMaskedFields(), beforeRow, labels)); Map extra = new LinkedHashMap<>(); // C2 的就近配对靠这两个键 join 旧表的 source_model_id / source_data_id,别改名 extra.put("sourceModelId", modelId); if (dataId != null) { extra.put("sourceDataId", dataId); } appendDmsWorkflowContext(extra, table, dataId, form); AuditCtx ctx = AuditCtx.parse(form.get("auditCtx"), event.getTenantId(), extra); if (ctx.flowTaskId != null) { extra.put("flowTaskId", ctx.flowTaskId); } 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"); } } String formName = registry.resolveFullName(modelId); String bizModule = resolveBizModule(entryName, formName, meta.getBizModule(), ctx.appCode); if (read) { recordRead(event, form, meta, table, dataId, ctx, extra, entryType, entryId, entryName, bizModule); 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 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 formFields = registry.resolveBaselineFields(modelId, table); List 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) { if (registry.hasPublishedFieldMetadata(modelId, table)) { fieldDiffs = AuditHiddenFieldDiffFilter.filter(fieldDiffs, labels); } else { fieldDiffs = null; extra.put("fieldDiffSource", "publishedMetadataUnavailable"); } } 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 configuredSignFields = registry.resolveSignFields(modelId, table); List signCandidateFields = AuditSignEvidenceReader.candidateFields( eventRow, titleSpec, configuredSignFields); // 🔴 候选是**带来源标记的三元组列表**,不是 field→signId 的 Map(2026-08-05 Codex // 复审 High①,理由见 REQUEST_SIGN_FIELD 的 javadoc)。顺序即优先级,与原先 // LinkedHashMap 的迭代序一致;来源由 SignCandidate.fromRequest 承担,与字段名解耦。 List signCandidates = new ArrayList<>(); Map evidences = Collections.emptyMap(); List 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 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 个不同的主人。 // 「creator_user_id …**对 verify_user 场景不能只看当前请求操作人**」。 // // 那能不能补个判据把「会签」与「盗用 signId」分开?**不能**——两者在数据上 // 长得一模一样:lims_sign 没有任何列记录「谁取得了这份凭证」,层 2 的 SIGN // 事件只覆盖 8/1306(0.6%)。信息不在表里,加谓词补不了(批 A 方案 B 同款形状)。 // 硬判的代价是把一次**真实的合规会签**明文记成「伪造尝试」,而审计表 append-only // 洗不掉——**「错的判成了」比「没判」更坏**(L075)。 // // 所以审计层的本分是**把不一致照出来,而不是拦下来**:签名事件照常产出、 // 正确记在签名人名下(会签的复核签名不能丢),同时如实标注「签名人≠操作人」, // 让审计员自己看。真正的拦截属于平台层同步校验(另开一轮), // 根治属于批 D 完整版(服务端消费签名时显式记归属 + 一次性消费)。 Map 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 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> historicalSignRefs = new ArrayList<>(); List> signAuthRejected = new ArrayList<>(); if (!signCandidates.isEmpty()) { // 删除路径**不查**:数据列候选恒判历史(P1b 规则不动),批量删 N 条一次都不查。 Set recorded = Collections.emptySet(); if (!delete) { List 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 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 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 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); 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) .menuId(ctx.menuId) .parentMenuId(ctx.parentMenuId) .entryName(entryName) .build(); auditEventService.saveIdempotent(dto); // ── 签名事件(spec §6)───────────────────────────────────────────────────── // **顺序不可调换**:业务事件先落库,签名事件才写。表单没保存成功 → 没有 MQ 事件 // → 这里根本不会执行 → 签名日志天然不产生。药厂要的「业务失败则签名也不记」 // 由链路结构保证,不靠事后补偿。 // // 每个签名一条,clientEventId 带 -SIGN- 后缀(多签名场景互不覆盖), // 靠 UNIQUE(client_event_id) 挡广播重复消费。 // 标题/入口/单号继承业务事件——即使单独按 eventType=E_SIGNATURE 查, // 也看得到"签的是哪张单子"。 // 请求顶层与保存后的数据列经常携带同一个 signId。候选必须都保留到动作语义判定, // 但签名事实只能输出一次;请求候选排在最前,因此重复时稳定保留请求来源的展示标签。 Set 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 signExtra = new LinkedHashMap<>(); signExtra.put("signField", candidate.signField); signExtra.put("sourceModelId", modelId); if (ctx.flowTaskId != null) { signExtra.put("flowTaskId", ctx.flowTaskId); } 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) .menuId(ctx.menuId) .parentMenuId(ctx.parentMenuId) .entryName(entryName) .build(); auditEventService.saveIdempotent(signDto); } } /** READ 不保存查询参数、返回数据或字段快照,只记录访问事实。 */ private void recordRead(ProjectEventInstance event, Map form, AuditFormRegistry.FormMeta meta, String table, String dataId, AuditCtx ctx, Map extra, String entryType, String entryId, String entryName, String bizModule) { 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(bizModule) .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) .menuId(ctx.menuId) .parentMenuId(ctx.parentMenuId) .entryName(entryName) .build(); auditEventService.saveIdempotent(dto); } /** * 这份签名候选是不是「本次操作」签的。三条互斥的分支,顺序即优先级: * *

    *
  1. 请求携带的恒为真({@link SignCandidate#fromRequest}):它按定义就是本次操作的 * 凭证,不需要、也不能拿历史反查去否定它;
  2. *
  3. 删除场景的数据列恒为假:被删数据里的签名列存的是历史,见下方长注释(P1b);
  4. *
  5. 非删除场景的数据列查 {@code recordedSignIds}:这个 signId 已经被记过一条 * 签名事件(且不是本次 operation 记的)⇒ 历史。见 {@link #recordedSignIds}。
  6. *
* *

非删除场景为什么不能像原先那样恒为真(2026-08-03 Codex 终审 High③):实测 * 一次复核保存会生成两条签名(确定 + 复核),分别落进 {@code biz_sign} / {@code fuhe_sign}; * {@code biz_sign} 每次保存都刷新成新的确定签名,而 {@code fuhe_sign} 只在复核那次写—— * 之后每次普通保存都会把它重发一条签名事件,{@code event_time} 停在上次复核那一刻却挂在 * 这次保存的 {@code operation_id} 下。就是 L078 骂的时间倒流,换到了更新路径上。 * *

删除场景只认请求携带的那一份({@code fromRequest},Task 1 平台补丁 * 从请求体的 {@code biz_sign} 传下来)。被删数据里的 {@code biz_sign}/{@code fuhe_sign} * 存的是这条记录创建/编辑时的历史签名,它们各自早在当时那次操作里记过一条签名事件了; * 在删除事件上重发一遍会同时踩三个坑(2026-08-02 端到端实测,audit_events id 39/40/43-46): *

    *
  1. 时间倒流:签名事件的 {@code event_time} 取签名凭证自身的创建时刻,于是 * 15:08 的一次删除产出了四条 {@code event_time} 停在 7-31 的「签名」事件, * 挂在这次删除的 {@code operation_id} 下——写入侧合并成一行后,看起来就是 * 「这次删除时签了四个名」,而事实是一个都没签在这次删除上;
  2. *
  3. 批量放大:批量删 N 条 × 每条 M 个历史签名列 = N×M 条签名事件,实测删 2 条 * 产出 4 条,本该只有用户真正签的那 1 条;
  4. *
  5. 动作语义取错:动作语义只认第一份证据,历史签名的 {@code meta_data} 会把 * 当时那次操作的 {@code biz_button}(实测「确定」)和 {@code biz_data} 当成这次删除的 * 上下文写进 {@code extra}。
  6. *
* *

为什么不干脆把它们从 {@code signCandidates} 里剔掉:这个列表还兼着 * 「哪些字段不许进标题」(见 {@code titleExcluded})。命名规则命中的签名列有 * {@code masked} 兜底,但 D4 的配置来源({@code confirmBtnConfig.biz_sign_field})允许 * 任意字段名,剔掉就等于让一个签名 id 明文进标题——审计表 append-only 存十年,洗不掉。 * 所以只在「产出事件」这一侧收窄。 * *

入参是 {@code fromRequest} 布尔量而不是字段名(2026-08-05 Codex 复审 High①): * 拿字段名与 {@link #REQUEST_SIGN_FIELD} 比对,等于让一个可碰撞的字符串来决定 * 「这份证据够不够格」——业务表真有同名列时判定就被劫持了。来源跟着候选走。 */ private static boolean signedByThisOperation(boolean delete, boolean fromRequest, String signId, Set 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 里哪些**已经被记过签名事件**(= 历史)。 * *

判据为什么取自 audit_events(用户 2026-08-03 拍板 D7):候选是不是「本次刚签的」, * 唯一不可篡改的依据就是「审计表里记过没有」。{@code lims_sign.meta_data}(含 * {@code biz_data})整块由前端 POST、后端原样落库、零校验,拿它判定等于把「凭空产出一条 * 签名事件」的口子交给前端报文;而 {@code data_snapshot} 里签名列是 {@code ***}, * 拿 baseline 比对「签名列变没变」也永远比不出来。 * *

为什么排除本次 operation_id:MQ 重投递会让同一条事件被消费两次,批量场景下 * N 条事件还共享同一个 {@code batchOpId}。不排除的话,第一条事件写下的签名事件会让后面 * 那些「同一次操作」的事件把它当成历史——判定必须对「同一次操作」保持稳定。 * *

自愈:某条签名当年因 MQ/落库失败没记上,下一次操作会把它当「本次」补一条。 * 补上的那条 {@code event_time} 仍是签名凭证自身的时刻,但 {@code operation_id} 属于补记 * 的那次操作——这是可见的代价,好过永远查不到这条签名。 * * @return 已记过的 signId 集合;查询失败返回 {@code null}——调用方据此走 D8 的 * fail-closed(全判历史 + {@code extra.signHistoryDegraded}),不要当成空集。 */ private Set recordedSignIds(Collection signIds, String operationId, String tenantId) { try { List rows = auditEventMapper.selectRecordedSignIds( AuditSignEvidenceReader.SIGN_TABLE, AuditConsts.TYPE_E_SIGNATURE, SIGN_ACTION_CODE, SIGN_SOURCE_LAYER, signIds, operationId, tenantId); Set 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⑤)。 * *

刻意不带本次的 operation/eventTime:这是历史事实的引用,不是本次操作的证据 * (L078 的规矩)。{@code signedAtMillis} 取签名凭证自身的创建时刻(epoch 毫秒), * 缺席表示凭证行读不到创建时刻,不是「没签过」。 */ private static Map historicalSignRef(String signField, String signId, AuditSignEvidence evidence) { Map 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 的字段(小写、稳定顺序)。 * *

要解决的事实(2026-07-30 联调报障,本机实测复现):在线表单保存时,平台会把值为空 * 的字段整个从 {@code newData} 里剥掉——{@code lims_qingyandan} 的 CREATE 事件快照只有 21 键, * 而已发布表单模型有 23 个字段,少的正是建单时留空的那几个。结果是这些字段**第一次被填写** * 时,UPDATE 分支的两侧交集({@code onlyFields} 互取对方 keySet)把它们整个排除, * {@code field_diffs} 漏记一次真实变更;它们进过一次快照之后反而正常了。 * *

补的是"已知为空",不是"猜一个旧值"。判据来自 * {@link AuditFormRegistry#resolveBaselineFields}:该字段属于**这张已发布表单**、且确是主表 * 一列。建单提交的就是整份表单,没被携带即等于留空——这是有据可依的推断,而不是对 * 数据库写入前快照里的未知字段瞎填。该补齐只用于 CREATE 的操作后快照,UPDATE 差异 * 始终直接使用数据库写入前后的实际查询结果。 * *

四类字段不补,每一类都有代价对应

* */ static List knownEmptyFields(Map eventRow, Set formFields, Set masked, Map labels) { if (formFields == null || formFields.isEmpty()) { return Collections.emptyList(); } Set carried = new java.util.HashSet<>(); if (eventRow != null) { for (String key : eventRow.keySet()) { if (key != null) { carried.add(key.toLowerCase(Locale.ROOT)); } } } List 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 快照;事件明确携带的值恒优先。 * *

用空串而不是 {@code null}:{@code data_snapshot} 落库前会过 * {@link AuditFieldDiff#snapshot}({@code stringify(null)} 同样得空串),两者存进去一模一样, * 但空串让人直接读 JSON 时看得出"这个字段当时就是空的",而不是像个漏写的键。 * 比较侧不受影响:{@link AuditFieldDiff#valuesEqual} 把 null 与空串一律视作空。 */ static Map withKnownEmpty(Map eventRow, List knownEmpty) { if (knownEmpty == null || knownEmpty.isEmpty()) { return eventRow; } Map row = new LinkedHashMap<>(); for (String field : knownEmpty) { row.put(field, ""); } if (eventRow != null) { row.putAll(eventRow); } return row; } /** * 从事件的 {@code listLog} 里提取「字段名 → 展示元数据」。 * *

这是层 0 相对层 1 白捡的一块元数据:listLog 由 visualdev 按**在线表单模型**生成, * 随表单发布自动更新,不需要任何手写映射表({@code AuditFieldDiff} 类注释里说的 * 「正确的来源是元数据」)。 * *

只取元数据、不取它的 oldData/newData——那两个是 visualdev 用不可信的 * oldData 算出来的**值**(L003),值一律以 PG baseline 为准。 * 取的三个({@code fieldName}/{@code jnpfKey}/{@code nameModified})都是**字段的属性** * 而不是这次改了什么,故不受 L003 约束。 * *

{@code jnpfKey}/{@code nameModified} 是 D1 补的(见 {@link AuditFieldDiff.FieldMeta}): * 它们各自对应前端一条渲染分支,缺了 {@code nameModified} 会把字典字段的存储原值当明文渲染。 */ private Map fieldMeta(Object listLog) { if (!(listLog instanceof List)) { return null; } Map meta = new LinkedHashMap<>(); for (Object item : (List) listLog) { Map 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> childDiffs(Object listLog) { if (!(listLog instanceof List)) { return Collections.emptyList(); } List> result = new ArrayList<>(); for (Object raw : (List) listLog) { if (!(raw instanceof Map)) { continue; } Map row = (Map) 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 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> childDiffs) { if (childDiffs == null || childDiffs.isEmpty()) { return fieldDiffs; } List merged = new ArrayList<>(); if (fieldDiffs != null && !fieldDiffs.trim().isEmpty()) { try { Object parsed = JSON.parse(fieldDiffs); if (parsed instanceof List) { merged.addAll((List) parsed); } } catch (Throwable ignored) { // 普通字段 JSON 损坏时仍保留可信的子表变化。 } } merged.addAll(childDiffs); return JSON.toJSONString(merged); } static Map withoutFields(Map row, Set fields) { if (row == null || row.isEmpty() || fields == null || fields.isEmpty()) { return row; } Set normalized = new LinkedHashSet<>(); for (String field : fields) { if (field != null) { normalized.add(field.toLowerCase(Locale.ROOT)); } } Map result = new LinkedHashMap<>(); for (Map.Entry entry : row.entrySet()) { if (entry.getKey() == null || !normalized.contains(entry.getKey().toLowerCase(Locale.ROOT))) { result.put(entry.getKey(), entry.getValue()); } } return result; } /** * 本次变更里命中「子表」控件的字段名。 * *

标准在线表单事件通过 listLog 携带平台按数据库前后数据算出的 {@code chidData}; * 只有补丁上线前的旧事件或非标准发布方缺少 listLog 时,层 0 才无法还原子表行级明细。 * 此时把「判不了」如实写进 extra,避免把空明细误解成「子表没有修改」。 */ private List subTableFields(Map meta) { if (meta == null) { return java.util.Collections.emptyList(); } List hit = new ArrayList<>(); for (Map.Entry e : meta.entrySet()) { if (AuditFieldDiff.JNPF_KEY_TABLE.equals(e.getValue().jnpfKey())) { hit.add(e.getKey()); } } return hit; } /** * 业务单号。取不到不丢事件(与旧 listener 的关键差异,见类注释)。 * 未配置取值字段时直接留空——大量表单本就没有业务单号这一说。 */ private String bizCode(Map newData, AuditFormRegistry.FormMeta meta) { String field = meta.getBizCodeField(); if (field == null || field.isEmpty() || newData == null) { return null; } return str(newData.get(field)); } /** * 层 0 的动作名只给纯动作词。 * *

模块语义已经由 {@code entry_name}(用户点进来的菜单)承担,再把它拼进动作名 * 就成了「LIMS-请验单 新建」这种既不是模块也不是动作的混合物。 * 层 2 事件(列表按钮)仍用各自的 {@code actionLabel}——那里的动作词是业务自定义的。 */ private String actionLabel(boolean create) { return create ? "新增" : "修改"; } /** * diff 的输入预处理:剔除平台技术列。脱敏列保留到比较阶段,确认变化后再隐藏值。 * *

为什么合成一个方法而不是在调用处各套两层:UPDATE 路径的 beforeRow 与 afterRow * 必须经**完全相同**的剔除规则,否则单边少剔一列就会产出一条假 diff(该列在一侧存在、 * 另一侧不存在)。把"两侧同规则"这件事交给代码结构保证,而不是靠调用处的注释约定 * ——约定会在下一次改动里悄悄失效(同 L066)。 * *

只用于 diff,不用于 {@code data_snapshot}:快照保留完整行(含技术列), * 剔除只发生在“要不要给人看这条变更”这一层。 * *

{@code static} + 包内可见是为了让 {@code AuditL0PartialSnapshotProbe} 直接驱动它: * “技术列被剔、脱敏列只隐藏值”这条口径的错法是静默地多出、少掉或泄露一列。 */ static Map forDiff(Map row) { return withoutTechColumns(row); } /** 比较同一次数据库写入前后的两份快照;单侧缺失字段按空值参与比较。 */ static String databaseBoundaryDiff(Map beforeRow, Map afterRow, Set masked, Map labels, Set childFields) { Map before = withoutFields(forDiff(beforeRow), childFields); Map after = withoutFields(forDiff(afterRow), childFields); return AuditFieldDiff.diff(before, after, masked, labels); } /** * 剔除平台技术列后的副本。 * * 业务字段禁止以 {@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} 等),无一例业务字段。 * 其中创建人、创建时间、最后修改人、最后修改时间是审计需要展示的操作责任信息,作为明确例外保留。 * *

顺带剔除裸 {@code id}:前端提交时把主键同时放在 {@code f_id} 和 {@code id} 两个键上, * 后者是别名、语义重复。 * *

为什么要剔: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 withoutTechColumns(Map row) { if (row == null || row.isEmpty()) { return row; } Map out = new LinkedHashMap<>(row.size()); for (Map.Entry 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 masked) { return column != null && masked.contains(column.toLowerCase(Locale.ROOT)); } /** * 签名凭证引用属于内建敏感字段,不依赖 Nacos 的 maskedFields 是否漏配。 */ private static Set effectiveMasked(Set configured, Map row, Map metadata) { Set 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 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 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; } /** * 把事件跨 JVM 序列化后的值统一成展示用字符串。写入前后的两侧应用完全相同的规则, * 避免 JSON 数值、日期等运行时类型差异产生假 diff。用 * {@link AuditFieldDiff#stringify} 保证最终展示值与层 1 的字段差异契约一致。 */ private Map stringifyValues(Map row) { if (row == null) { return null; } Map out = new LinkedHashMap<>(row.size()); for (Map.Entry 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 asMap(Object source) { if (source == null) { return null; } if (source instanceof Map) { return (Map) source; } Object json = JSON.toJSON(source); return json instanceof Map ? (Map) json : null; } private static String str(Object value) { if (value == null) { return null; } String s = value.toString().trim(); return s.isEmpty() ? null : s; } /** 当前菜单和表单共同描述业务模块;静态配置仅在动态名称均不可得时兜底。 */ static String resolveBizModule(String menuName, String formName, String configuredBizModule, String appCode) { String menu = str(menuName); String form = str(formName); if (menu != null && form != null) { return menu.equals(form) ? menu : menu + "/" + form; } if (menu != null) { return menu; } if (form != null) { return form; } String configured = str(configuredBizModule); return configured != null ? configured : str(appCode); } /** * 客户端显示值只能补充标题解析,不能决定标题字段。这里按发布表单主字段白名单、 * 脱敏/签名排除集和固定长度上限收口,非法或多余字段直接丢弃。 */ private static Map frontendDisplayValues(Object raw, AuditTitleSpec spec, Set excludedFields) { if (!(raw instanceof Collection) || spec == null || spec.mainFields().isEmpty()) { return Collections.emptyMap(); } Set allowed = new LinkedHashSet(spec.mainFields()); Map result = new LinkedHashMap(); 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 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; } /** * 一份签名候选:值({@code signId})、来源({@code fromRequest})、 * 判定结果({@code thisOperation})三者绑在一起走完全程。 * *

为什么不是 {@code Map signFieldToId}(2026-08-05 Codex 复审 High①): * 那个结构把「来源」编码进了 map 的 key(伪字段名 {@link #REQUEST_SIGN_FIELD}), * 于是一个可碰撞的字符串同时承担了标识与授权两件事——业务表只要有同名列,数据列证据 * 就会被当成请求来源改写 {@code action_code},且 {@code putAll} 会用数据列的值覆盖请求值。 * 判定结果同理:原先记在一个「字段名集合」里,也是按名字装。名字不是身份, * 来源与判定都必须是候选自己的属性(L081:来源是属性,不能从内容反推)。 * *

{@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; } } /** * *

发布方把它序列化成 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; private String parentMenuId; private String flowTaskId; /** 请求线程固化的 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 extra) { AuditCtx ctx = new AuditCtx(); Map 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.parentMenuId = str(src.get("parentMenuId")); ctx.flowTaskId = str(src.get("flowTaskId")); 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 castMap(Object raw) { return (Map) 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; } } }