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),影子期两条链路互不干扰。
*
*
* - L002:整个 handler 包 try/catch(Throwable)。JNPF 的 {@code ProjectEventProccess}
* 是 for-iterator 分发,任一 consumer 抛异常会 break 整个循环——同 channel 上的其它
* listener(含 lims 旧 listener)会跟着一起哑掉。审计绝不能拖垮别人。
* - L003:UPDATE 使用 visualdev 在数据库写入前、写入后分别查询得到的
* {@code oldData}/{@code newData}。两份数据必须随同一事件固化,MQ 消费端不回读业务表,
* 避免异步延迟读到后续操作产生的未来状态。
* - L004:跨 JVM 后 source 是 {@code JSONObject},当 Map 取。
* {@code VisualLogForm} 用了 {@code @Builder} 没有无参构造器,反序列化不回去。
*
*
* 数据库写入边界快照
* 每条层 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