From 34981c30a78e8bbd7791131059a9210f9928b62c Mon Sep 17 00:00:00 2001
From: 刘光辉 <347230014@qq.com>
Date: 星期四, 17 九月 2026 09:24:09 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/master' into master

---
 jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/impl/AuditEventServiceImpl.java |  895 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 895 insertions(+), 0 deletions(-)

diff --git a/jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/impl/AuditEventServiceImpl.java b/jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/impl/AuditEventServiceImpl.java
new file mode 100644
index 0000000..3c42da7
--- /dev/null
+++ b/jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/impl/AuditEventServiceImpl.java
@@ -0,0 +1,895 @@
+package jnpf.bizcommon.audit.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import jnpf.audit.model.AuditEventDTO;
+import jnpf.audit.AuditConsts;
+import jnpf.bizcommon.audit.entity.AuditEventEntity;
+import jnpf.bizcommon.audit.mapper.AuditEventMapper;
+import jnpf.bizcommon.audit.service.AuditEventService;
+import jnpf.bizcommon.audit.service.AuditFormRegistry;
+import jnpf.bizcommon.audit.service.support.AuditDiffKey;
+import jnpf.bizcommon.audit.service.support.AuditEventCategories;
+import jnpf.util.JsonUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+@Slf4j
+@Service
+public class AuditEventServiceImpl implements AuditEventService {
+
+    @Autowired
+    private AuditEventMapper auditEventMapper;
+
+    @Autowired
+    private AuditFormRegistry auditFormRegistry;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int saveIdempotent(AuditEventDTO dto) {
+        validate(dto);
+        AuditEventEntity entity = toEntity(dto);
+        entity.setEventCategory(AuditEventCategories.resolve(entity.getEventType(), entity.getExtra()));
+        entity.setFieldDiffs(scopeFieldDiffs(entity.getFieldDiffs(),
+                entity.getTargetTable(), entity.getTargetId()));
+        entity.setFieldDiffs(filterFieldDiffs(entity.getFieldDiffs(), entity.getTargetTable(),
+                auditFormRegistry::resolvePhysicalColumns));
+        int rows = auditEventMapper.insertIgnore(entity);
+        if (rows > 0 || isBlank(entity.getOperationId())) {
+            return rows;
+        }
+
+        AuditEventEntity existing = auditEventMapper.selectByOperationCategoryForUpdate(
+                entity.getOperationId(), entity.getEventCategory());
+        if (existing == null || entity.getClientEventId().equals(existing.getClientEventId())) {
+            log.info("[audit] duplicate client_event_id ignored, clientEventId={}", dto.getClientEventId());
+            return 0;
+        }
+        mergeInto(existing, entity);
+        auditEventMapper.updateMerged(existing);
+        log.info("[audit] merged operation event, operationId={}, category={}, sourceClientEventId={}",
+                entity.getOperationId(), entity.getEventCategory(), entity.getClientEventId());
+        return 1;
+    }
+
+    private void mergeInto(AuditEventEntity existing, AuditEventEntity incoming) {
+        // 鍗囩骇鍓嶇殑宸插瓨浜嬩欢娌℃湁閫愰」 targetId锛涘弬涓庤繜鍒颁簨浠�/閲嶈瘯鍚堝苟鏃讹紝鐢ㄤ簨浠堕《灞備富閿ˉ榻愩��
+        // 鍙慨鏀规湰娆¢攣瀹氱殑鍐呭瓨瀹炰綋锛屽巻鍙叉暟鎹棤闇�绂荤嚎杩佺Щ銆�
+        existing.setFieldDiffs(scopeFieldDiffs(existing.getFieldDiffs(),
+                existing.getTargetTable(), existing.getTargetId()));
+        Map<String, Object> existingExtra = parseExtra(existing.getExtra());
+        Map<String, Object> incomingExtra = parseExtra(incoming.getExtra());
+        String existingSnapshotKey = snapshotKey(existing.getTargetTable(), existing.getTargetId());
+        String existingSnapshot = existing.getDataSnapshot();
+        String incomingSnapshotKey = snapshotKey(incoming.getTargetTable(), incoming.getTargetId());
+        String incomingSnapshot = incoming.getDataSnapshot();
+        long existingTime = time(existing.getEventTime());
+        long incomingTime = time(incoming.getEventTime());
+        long firstTime = longValue(existingExtra.get("mergeFirstEventTime"), existingTime);
+        long lastTime = longValue(existingExtra.get("mergeLastEventTime"), existingTime);
+        boolean incomingIsEarlier = incomingTime < firstTime;
+        boolean incomingIsLater = incomingTime >= lastTime;
+        boolean business = AuditEventCategories.BUSINESS.equals(existing.getEventCategory());
+        boolean existingIsFormSource = business && Integer.valueOf(0).equals(existing.getSourceLayer());
+        boolean existingHasFormSource = business && (existingIsFormSource
+                || Boolean.TRUE.equals(existingExtra.get("mergeHasFormSource")));
+        boolean incomingIsFormSource = business && Integer.valueOf(0).equals(incoming.getSourceLayer());
+        long lastSupplementTime = longValue(existingExtra.get("mergeLastSupplementEventTime"),
+                existingIsFormSource ? Long.MIN_VALUE : existingTime);
+        boolean incomingIsLatestSupplement = !incomingIsFormSource && incomingTime >= lastSupplementTime;
+        Set<String> formProtectedFields = normalizedProtectedFields(
+                existingExtra.get("mergeFormProtectedFields"));
+        if (business && Integer.valueOf(0).equals(existing.getSourceLayer())) {
+            formProtectedFields.addAll(protectedFormFields(existing.getFieldDiffs()));
+        }
+        if (incomingIsFormSource) {
+            formProtectedFields.addAll(protectedFormFields(incoming.getFieldDiffs()));
+        }
+
+        String mergedDiffs = mergeFieldDiffs(
+                existing.getFieldDiffs(), incoming.getFieldDiffs(), incomingIsEarlier,
+                existingHasFormSource ? incomingIsLatestSupplement : incomingIsLater,
+                existingHasFormSource, incomingIsFormSource, formProtectedFields);
+        String storedFormSnapshot = strValue(existingExtra.get("mergeFormSnapshot"));
+        String storedFormSnapshotKey = strValue(existingExtra.get("mergeFormSnapshotKey"));
+        String formSnapshot = incomingIsFormSource ? incomingSnapshot
+                : (storedFormSnapshot != null ? storedFormSnapshot
+                : (existingIsFormSource ? existingSnapshot : null));
+        String mergedSnapshot = mergeSnapshots(existingSnapshot, incomingSnapshot,
+                incomingIsLater, incomingIsLatestSupplement, business,
+                existingHasFormSource, incomingIsFormSource,
+                formSnapshot);
+        int incomingPriority = sourcePriority(existing.getEventCategory(), incoming.getSourceLayer());
+        int existingPriority = sourcePriority(existing.getEventCategory(), existing.getSourceLayer());
+        boolean preferIncoming = incomingPriority > existingPriority
+                || (incomingPriority == existingPriority && incomingIsLater);
+        if (preferIncoming) {
+            copyPresentation(existing, incoming);
+        }
+        existing.setEventTime(new Date(Math.max(lastTime, incomingTime)));
+        existing.setFieldDiffs(mergedDiffs);
+        existing.setDataSnapshot(mergedSnapshot);
+        existing.setExtra(mergeExtra(existingExtra, incomingExtra, existing.getClientEventId(),
+                incoming.getClientEventId(), Math.min(firstTime, incomingTime), Math.max(lastTime, incomingTime),
+                existingSnapshotKey, existingSnapshot, incomingSnapshotKey, incomingSnapshot,
+                existingHasFormSource || incomingIsFormSource, formProtectedFields,
+                formSnapshot, incomingIsFormSource ? incomingSnapshotKey
+                        : (storedFormSnapshotKey != null
+                        ? storedFormSnapshotKey
+                        : (existingIsFormSource ? existingSnapshotKey : null)),
+                incomingIsLatestSupplement ? incomingTime : lastSupplementTime));
+    }
+
+    static String mergeFieldDiffs(String existingJson, String incomingJson,
+                                  boolean incomingIsEarlier, boolean incomingIsLater) {
+        return mergeFieldDiffs(existingJson, incomingJson, incomingIsEarlier, incomingIsLater,
+                false, false, Collections.emptySet());
+    }
+
+    static String mergeFieldDiffs(String existingJson, String incomingJson,
+                                  boolean incomingIsEarlier, boolean incomingIsLater,
+                                  boolean existingHasFormSource, boolean incomingIsFormSource,
+                                  Set<String> formProtectedFields) {
+        if (incomingIsFormSource) {
+            // 鏃犺 MQ 璋佸厛鍒帮紝宸叉湁鎺ュ彛宸紓閮藉彧鑳戒綔涓鸿〃鍗曞熀绾跨殑琛ュ厖銆�
+            return mergeFormFirstDiffs(incomingJson, existingJson, true, formProtectedFields);
+        }
+        if (existingHasFormSource) {
+            return mergeFormFirstDiffs(existingJson, incomingJson, incomingIsLater, formProtectedFields);
+        }
+        LinkedHashMap<String, Map<String, Object>> merged = new LinkedHashMap<>();
+        addDiffs(merged, existingJson, false, true);
+        addDiffs(merged, incomingJson, incomingIsEarlier, incomingIsLater);
+        return merged.isEmpty() ? null : JSON.toJSONString(new ArrayList<>(merged.values()));
+    }
+
+    /** 琛ㄥ崟宸紓鍐冲畾瀛楁椤哄簭銆佸垵濮嬪�间笌灞曠ず鍏冩暟鎹紱鎺ュ彛鍙ˉ鍏呰〃鍗曠己澶便�佺┖鍊兼垨闆跺�煎瓧娈点�� */
+    private static String mergeFormFirstDiffs(String formFirstJson, String supplementalJson,
+                                              boolean supplementIsLater,
+                                              Set<String> formProtectedFields) {
+        LinkedHashMap<String, Map<String, Object>> merged = new LinkedHashMap<>();
+        addDiffs(merged, formFirstJson, false, true);
+        addSupplementalDiffs(merged, supplementalJson, supplementIsLater, formProtectedFields);
+        return merged.isEmpty() ? null : JSON.toJSONString(new ArrayList<>(merged.values()));
+    }
+
+    @SuppressWarnings("unchecked")
+    private static void addSupplementalDiffs(LinkedHashMap<String, Map<String, Object>> merged,
+                                             String json, boolean replaceNew,
+                                             Set<String> formProtectedFields) {
+        if (isBlank(json)) {
+            return;
+        }
+        try {
+            Object parsed = JSON.parse(json);
+            if (!(parsed instanceof List)) {
+                return;
+            }
+            for (Object raw : (List<?>) parsed) {
+                if (!(raw instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> diff = new LinkedHashMap<>((Map<String, Object>) raw);
+                String field = strValue(diff.get("field"));
+                if (field == null) {
+                    continue;
+                }
+                Map<String, Object> current = merged.get(recordDiffKey(diff));
+                if (current == null) {
+                    merged.put(recordDiffKey(diff), diff);
+                } else if (!isFormProtected(formProtectedFields, diff)
+                        && hasDataValue(diff.get("newData"))
+                        && (replaceNew || !hasDataValue(current.get("newData")))) {
+                    copyIfPresent(diff, current, "newData");
+                    if (diff.containsKey("newDisplay")) {
+                        current.put("newDisplay", diff.get("newDisplay"));
+                    } else if (diff.containsKey("newData")) {
+                        current.remove("newDisplay");
+                    }
+                }
+            }
+        } catch (Throwable ignored) {
+            // 鎹熷潖鐨勮ˉ鍏� diff 涓嶅緱鐮村潖宸茬粡鍙栧緱鐨勮〃鍗曞熀绾裤��
+        }
+    }
+
+    private static String strValue(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        return text.isEmpty() ? null : text;
+    }
+
+    private static Set<String> protectedFormFields(String json) {
+        Set<String> fields = new LinkedHashSet<>();
+        if (isBlank(json)) {
+            return fields;
+        }
+        try {
+            Object parsed = JSON.parse(json);
+            if (!(parsed instanceof List)) {
+                return fields;
+            }
+            for (Object raw : (List<?>) parsed) {
+                if (!(raw instanceof Map)) {
+                    continue;
+                }
+                Map<?, ?> diff = (Map<?, ?>) raw;
+                String field = strValue(diff.get("field"));
+                if (field != null && (hasDataValue(diff.get("newData")) || hasChildDiff(diff))) {
+                    fields.add(fieldDiffKey(diff));
+                }
+            }
+        } catch (Throwable ignored) {
+            // 瑙f瀽澶辫触鏃朵笉鐚滀繚鎶ゅ瓧娈碉紝鍘熷琛ㄥ崟宸紓浠嶄細浣滀负鍚堝苟鍩虹嚎淇濈暀銆�
+        }
+        return fields;
+    }
+
+    private static boolean isFormProtected(Set<String> protectedFields, Map<String, Object> diff) {
+        if (protectedFields.contains(fieldDiffKey(diff))) {
+            return true;
+        }
+        boolean hasScopedKey = protectedFields.stream().anyMatch(AuditDiffKey::isScoped);
+        // 鍏煎淇鍓嶅凡鍐欏叆 extra 鐨勬棤琛ㄤ綔鐢ㄥ煙瀛楁鍚嶃��
+        return !hasScopedKey && protectedFields.contains(strValue(diff.get("field")));
+    }
+
+    /** 鐢ㄦ埛瀹氫箟鐨勭┖鍊艰鍒欙細null銆佺┖瀛楃涓插強鏁板�奸浂鍙敱鎺ュ彛琛ュ厖锛屽叾浣欒〃鍗曞�间笉鍙鐩栥�� */
+    private static boolean hasDataValue(Object value) {
+        if (value == null) {
+            return false;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue() != 0D;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.isEmpty()) {
+            return false;
+        }
+        try {
+            return new java.math.BigDecimal(text).compareTo(java.math.BigDecimal.ZERO) != 0;
+        } catch (NumberFormatException ignored) {
+            return true;
+        }
+    }
+
+    private static boolean hasChildDiff(Map<?, ?> diff) {
+        Object data = diff == null ? null : diff.get("chidData");
+        return data instanceof Collection && !((Collection<?>) data).isEmpty();
+    }
+
+    static String mergeSnapshots(String existingJson, String incomingJson,
+                                 boolean incomingIsLater, boolean incomingIsLatestSupplement,
+                                 boolean business,
+                                 boolean existingHasFormSource, boolean incomingIsFormSource,
+                                 String formSnapshot) {
+        if (business && !isBlank(formSnapshot)
+                && (existingHasFormSource || incomingIsFormSource)) {
+            if (incomingIsFormSource) {
+                return mergeFormFirstSnapshots(formSnapshot, existingJson);
+            }
+            return incomingIsLatestSupplement
+                    ? mergeFormFirstSnapshots(formSnapshot, existingJson, incomingJson)
+                    : mergeFormFirstSnapshots(formSnapshot, incomingJson, existingJson);
+        }
+        return incomingJson != null && incomingIsLater ? incomingJson : existingJson;
+    }
+
+    /** 琛ㄥ崟蹇収鍐冲畾瀛楁椤哄簭鍜屾湁鏁堝�硷紱鍏朵粬鏉ユ簮鍙ˉ瀛楁锛屾垨濉厖琛ㄥ崟涓殑 null銆佺┖涓插拰闆跺�笺�� */
+    @SuppressWarnings("unchecked")
+    static String mergeFormFirstSnapshots(String formJson, String... supplementalJson) {
+        Map<String, Object> form = parseJsonObject(formJson);
+        if (form == null) {
+            return formJson;
+        }
+        Map<String, Object> merged = new LinkedHashMap<>(form);
+        Set<String> protectedFields = new LinkedHashSet<>();
+        for (Map.Entry<String, Object> entry : form.entrySet()) {
+            if (hasDataValue(entry.getValue())) {
+                protectedFields.add(entry.getKey());
+            }
+        }
+        for (String json : supplementalJson) {
+            Map<String, Object> supplemental = parseJsonObject(json);
+            if (supplemental == null) {
+                continue;
+            }
+            for (Map.Entry<String, Object> entry : supplemental.entrySet()) {
+                if (!protectedFields.contains(entry.getKey()) && hasDataValue(entry.getValue())) {
+                    merged.put(entry.getKey(), entry.getValue());
+                }
+            }
+        }
+        return JSON.toJSONString(merged);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> parseJsonObject(String json) {
+        if (isBlank(json)) {
+            return null;
+        }
+        try {
+            Object parsed = JSON.parse(json);
+            return parsed instanceof Map
+                    ? new LinkedHashMap<>((Map<String, Object>) parsed) : null;
+        } catch (Throwable ignored) {
+            return null;
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static void addDiffs(LinkedHashMap<String, Map<String, Object>> merged, String json,
+                                 boolean replaceOld, boolean replaceNew) {
+        if (isBlank(json)) {
+            return;
+        }
+        try {
+            Object parsed = JSON.parse(json);
+            if (!(parsed instanceof List)) {
+                return;
+            }
+            for (Object raw : (List<?>) parsed) {
+                if (!(raw instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> diff = new LinkedHashMap<>((Map<String, Object>) raw);
+                Object rawField = diff.get("field");
+                if (rawField == null || isBlank(String.valueOf(rawField))) {
+                    continue;
+                }
+                String field = String.valueOf(rawField);
+                String key = recordDiffKey(diff);
+                Map<String, Object> current = merged.get(key);
+                if (current == null) {
+                    merged.put(key, diff);
+                    continue;
+                }
+                if (replaceOld) {
+                    copyIfPresent(diff, current, "oldData");
+                    copyIfPresent(diff, current, "oldDisplay");
+                }
+                if (replaceNew) {
+                    copyIfPresent(diff, current, "newData");
+                    copyIfPresent(diff, current, "newDisplay");
+                    copyMetadata(diff, current);
+                }
+            }
+        } catch (Throwable ignored) {
+            // 鎹熷潖鐨勫巻鍙� diff 涓嶅弬涓庣粍鍚堬紝鍚庣画鍚堟硶鏉ユ簮浠嶅彲琛ラ綈銆�
+        }
+    }
+
+    private static void copyMetadata(Map<String, Object> source, Map<String, Object> target) {
+        String[] keys = {"fieldName", "changeType", "componentType", "jnpfKey", "masked",
+                "nameModified", "technical", "type", "valueType", "chidData", "chidField",
+                "targetTable", "targetId"};
+        for (String key : keys) {
+            Object value = source.get(key);
+            if (value != null && !String.valueOf(value).isEmpty()) {
+                target.put(key, value);
+            }
+        }
+    }
+
+    private static String fieldDiffKey(Map<?, ?> diff) {
+        String field = strValue(diff == null ? null : diff.get("field"));
+        String table = strValue(diff == null ? null : diff.get("targetTable"));
+        return AuditDiffKey.of(table, field);
+    }
+
+    /**
+     * 鎵归噺鎿嶄綔蹇呴』鎸夎褰曢殧绂诲悓鍚嶅瓧娈碉紱鏃ф棩蹇楁病鏈� targetId 鏃堕��鍥炲師鏈夎〃+瀛楁閿��
+     */
+    private static String recordDiffKey(Map<?, ?> diff) {
+        String fieldKey = fieldDiffKey(diff);
+        String targetId = strValue(diff == null ? null : diff.get("targetId"));
+        return targetId == null ? fieldKey
+                : fieldKey + "@target:" + targetId.length() + ":" + targetId;
+    }
+
+    /**
+     * 鍗曡褰曠敓浜х鏃犻渶閲嶅濉啓浣滅敤鍩燂紱鎵归噺鐢熶骇绔樉寮忔惡甯︾殑閫愰」 targetId 浼樺厛淇濈暀銆�
+     */
+    @SuppressWarnings("unchecked")
+    static String scopeFieldDiffs(String json, String defaultTable, String defaultTargetId) {
+        if (isBlank(json)) {
+            return json;
+        }
+        try {
+            Object parsed = JSON.parse(json);
+            if (!(parsed instanceof List)) {
+                return json;
+            }
+            List<Map<String, Object>> scoped = new ArrayList<>();
+            for (Object raw : (List<?>) parsed) {
+                if (!(raw instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> item = new LinkedHashMap<>((Map<String, Object>) raw);
+                if (strValue(item.get("targetTable")) == null && !isBlank(defaultTable)) {
+                    item.put("targetTable", defaultTable);
+                }
+                if (strValue(item.get("targetId")) == null && !isBlank(defaultTargetId)) {
+                    item.put("targetId", defaultTargetId);
+                }
+                scoped.add(item);
+            }
+            return JSON.toJSONString(scoped);
+        } catch (Throwable ignored) {
+            return json;
+        }
+    }
+
+    /** 姣忎釜瀛楁鍙寜鍏舵墍灞炵墿鐞嗚〃鏍¢獙锛涘厓鏁版嵁涓嶅彲鐢ㄦ椂淇濈暀鍘熷宸紓锛岄伩鍏嶉潤榛樹涪瀹¤銆� */
+    @SuppressWarnings("unchecked")
+    static String filterFieldDiffs(String json, String defaultTable,
+                                   Function<String, Set<String>> columnResolver) {
+        if (isBlank(json) || columnResolver == null) {
+            return json;
+        }
+        try {
+            Object parsed = JSON.parse(json);
+            if (!(parsed instanceof List)) {
+                return json;
+            }
+            List<Map<String, Object>> filtered = new ArrayList<>();
+            for (Object raw : (List<?>) parsed) {
+                if (!(raw instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> item = new LinkedHashMap<>((Map<String, Object>) raw);
+                boolean child = "table".equalsIgnoreCase(strValue(item.get("jnpfKey")));
+                String table = strValue(item.get("targetTable"));
+                if (!child && table == null) {
+                    table = strValue(defaultTable);
+                }
+                if (table == null) {
+                    // 鏃у瓙琛ㄤ簨浠舵病鏈夌墿鐞嗚〃褰掑睘锛屾棤娉曞畨鍏ㄥ垽鏂紱淇濈暀璇佹嵁锛屼笉鎷夸富琛ㄥ垪璇垹銆�
+                    filtered.add(item);
+                    continue;
+                }
+                table = table.toLowerCase(Locale.ROOT);
+                item.put("targetTable", table);
+                Set<String> resolved = columnResolver.apply(table);
+                Set<String> columns = lowerSet(resolved);
+                if (columns.isEmpty()) {
+                    filtered.add(item);
+                    continue;
+                }
+                if (child) {
+                    Map<String, Object> childItem = filterChildDiff(item, columns);
+                    if (childItem != null) {
+                        filtered.add(childItem);
+                    }
+                    continue;
+                }
+                String field = strValue(item.get("field"));
+                if (field != null && columns.contains(field.toLowerCase(Locale.ROOT))) {
+                    filtered.add(item);
+                }
+            }
+            return JSON.toJSONString(filtered);
+        } catch (Throwable ignored) {
+            return json;
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> filterChildDiff(Map<String, Object> item, Set<String> columns) {
+        List<Map<String, Object>> fields = new ArrayList<>();
+        Object rawFields = item.get("chidField");
+        if (rawFields instanceof List) {
+            for (Object raw : (List<?>) rawFields) {
+                if (!(raw instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> field = new LinkedHashMap<>((Map<String, Object>) raw);
+                String prop = strValue(field.get("prop"));
+                if (prop != null && columns.contains(prop.toLowerCase(Locale.ROOT))) {
+                    fields.add(field);
+                }
+            }
+        }
+        if (fields.isEmpty()) {
+            return null;
+        }
+
+        Set<String> allowed = new LinkedHashSet<>();
+        for (Map<String, Object> field : fields) {
+            allowed.add(strValue(field.get("prop")).toLowerCase(Locale.ROOT));
+        }
+        List<Map<String, Object>> rows = new ArrayList<>();
+        Object rawRows = item.get("chidData");
+        if (rawRows instanceof List) {
+            for (Object raw : (List<?>) rawRows) {
+                if (!(raw instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> row = new LinkedHashMap<>();
+                boolean hasBusinessField = false;
+                for (Map.Entry<?, ?> entry : ((Map<?, ?>) raw).entrySet()) {
+                    String key = entry.getKey() == null ? null : String.valueOf(entry.getKey());
+                    if (key == null) {
+                        continue;
+                    }
+                    String normalized = key.toLowerCase(Locale.ROOT);
+                    String actual = normalized.startsWith("jnpf_old_")
+                            ? normalized.substring("jnpf_old_".length()) : normalized;
+                    if (allowed.contains(actual)) {
+                        row.put(key, entry.getValue());
+                        hasBusinessField = true;
+                    } else if ("jnpf_type".equals(normalized)) {
+                        row.put(key, entry.getValue());
+                    }
+                }
+                if (hasBusinessField) {
+                    rows.add(row);
+                }
+            }
+        }
+        if (rows.isEmpty()) {
+            return null;
+        }
+        item.put("chidField", fields);
+        item.put("chidData", rows);
+        return item;
+    }
+
+    private static Set<String> lowerSet(Set<String> values) {
+        if (values == null || values.isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<String> result = new LinkedHashSet<>();
+        for (String value : values) {
+            if (value != null && !value.trim().isEmpty()) {
+                result.add(value.trim().toLowerCase(Locale.ROOT));
+            }
+        }
+        return result;
+    }
+
+    private static void copyIfPresent(Map<String, Object> source, Map<String, Object> target, String key) {
+        if (source.containsKey(key)) {
+            target.put(key, source.get(key));
+        }
+    }
+
+    private static String mergeExtra(Map<String, Object> existing, Map<String, Object> incoming,
+                                     String existingClientId, String incomingClientId,
+                                     long firstTime, long lastTime,
+                                     String existingSnapshotKey, String existingSnapshot,
+                                     String incomingSnapshotKey, String incomingSnapshot,
+                                     boolean hasFormSource, Set<String> formProtectedFields,
+                                     String formSnapshot, String formSnapshotKey,
+                                     long lastSupplementEventTime) {
+        Map<String, Object> merged = new LinkedHashMap<>(existing);
+        merged.putAll(incoming);
+        Set<String> sourceIds = new LinkedHashSet<>();
+        addStrings(sourceIds, existing.get("mergedClientEventIds"));
+        sourceIds.add(existingClientId);
+        sourceIds.add(incomingClientId);
+        merged.put("mergedClientEventIds", new ArrayList<>(sourceIds));
+        merged.put("mergeFirstEventTime", firstTime);
+        merged.put("mergeLastEventTime", lastTime);
+        if (hasFormSource) {
+            merged.put("mergeHasFormSource", true);
+            merged.put("mergeFormProtectedFields", new ArrayList<>(formProtectedFields));
+            if (formSnapshot != null) {
+                merged.put("mergeFormSnapshot", formSnapshot);
+            }
+            if (formSnapshotKey != null) {
+                merged.put("mergeFormSnapshotKey", formSnapshotKey);
+            }
+        }
+        if (lastSupplementEventTime != Long.MIN_VALUE) {
+            merged.put("mergeLastSupplementEventTime", lastSupplementEventTime);
+        }
+        Map<String, Object> snapshots = objectMap(existing.get("mergedSnapshots"));
+        if (existingSnapshotKey != null && existingSnapshot != null) {
+            snapshots.put(existingSnapshotKey, existingSnapshot);
+        }
+        if (incomingSnapshotKey != null && incomingSnapshot != null) {
+            snapshots.put(incomingSnapshotKey, incomingSnapshot);
+        }
+        if (!snapshots.isEmpty()) {
+            merged.put("mergedSnapshots", snapshots);
+        }
+        return JsonUtil.getObjectToString(merged);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> objectMap(Object value) {
+        if (value instanceof Map) {
+            return new LinkedHashMap<>((Map<String, Object>) value);
+        }
+        return new LinkedHashMap<>();
+    }
+
+    private static String snapshotKey(String targetTable, String targetId) {
+        return isBlank(targetTable) || isBlank(targetId) ? null : targetTable + ":" + targetId;
+    }
+
+    private static Map<String, Object> parseExtra(String json) {
+        if (!isBlank(json)) {
+            try {
+                Map<String, Object> parsed = JsonUtil.stringToMap(json);
+                if (parsed != null) {
+                    return new LinkedHashMap<>(parsed);
+                }
+            } catch (Throwable ignored) {
+                // 鍘熸枃鍦ㄤ笅闈繚鐣欙紝閬垮厤闈欓粯涓㈠け鎹熷潖鐨勫巻鍙� extra銆�
+            }
+        }
+        Map<String, Object> result = new LinkedHashMap<>();
+        if (!isBlank(json)) {
+            result.put("mergedOriginalExtra", json);
+        }
+        return result;
+    }
+
+    private static void addStrings(Set<String> target, Object value) {
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                if (item != null) {
+                    target.add(String.valueOf(item));
+                }
+            }
+        }
+    }
+
+    static Set<String> normalizedProtectedFields(Object value) {
+        Set<String> result = new LinkedHashSet<>();
+        if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                if (item != null) {
+                    String normalized = AuditDiffKey.normalizeStored(String.valueOf(item));
+                    if (normalized != null) {
+                        result.add(normalized);
+                    }
+                }
+            }
+        }
+        return result;
+    }
+
+    static int sourcePriority(String category, Integer sourceLayer) {
+        if (AuditEventCategories.BUSINESS.equals(category)) {
+            return Integer.valueOf(0).equals(sourceLayer) ? 3
+                    : (Integer.valueOf(2).equals(sourceLayer) ? 2 : 1);
+        }
+        if (Integer.valueOf(0).equals(sourceLayer)) {
+            return 3; // 灞� 0 鏄疄闄呯鍚嶅嚟璇侊紝浼樺厛浜� SIGN_USED 绛変娇鐢ㄨ褰曘��
+        }
+        return Integer.valueOf(2).equals(sourceLayer) ? 2 : 1;
+    }
+
+    private static void copyPresentation(AuditEventEntity target, AuditEventEntity source) {
+        target.setTenantId(source.getTenantId());
+        target.setOperatorId(source.getOperatorId());
+        target.setOperatorName(source.getOperatorName());
+        target.setOperatorOrgId(source.getOperatorOrgId());
+        target.setAppName(source.getAppName());
+        target.setBizModule(source.getBizModule());
+        target.setIp(source.getIp());
+        target.setIpRegion(source.getIpRegion());
+        target.setBrowser(source.getBrowser());
+        target.setOs(source.getOs());
+        target.setRequestUri(source.getRequestUri());
+        target.setEventType(source.getEventType());
+        target.setActionCode(source.getActionCode());
+        target.setActionLabel(source.getActionLabel());
+        target.setSourceLayer(source.getSourceLayer());
+        target.setTargetTable(source.getTargetTable());
+        target.setTargetId(source.getTargetId());
+        target.setBizType(source.getBizType());
+        target.setBizCode(source.getBizCode());
+        target.setReason(source.getReason());
+        target.setRecordTitle(source.getRecordTitle());
+        target.setEntryType(source.getEntryType());
+        target.setEntryId(source.getEntryId());
+        target.setEntryName(source.getEntryName());
+        target.setPrevHash(source.getPrevHash());
+    }
+
+    private static long time(Date value) {
+        return value == null ? 0L : value.getTime();
+    }
+
+    private static long longValue(Object value, long fallback) {
+        if (value instanceof Number) {
+            return ((Number) value).longValue();
+        }
+        try {
+            return value == null ? fallback : Long.parseLong(String.valueOf(value));
+        } catch (NumberFormatException ignored) {
+            return fallback;
+        }
+    }
+
+    /**
+     * 鏍¢獙瑙勫垯锛坰pec v2.2 瑁佸畾 + 杩藉姞瑁佸畾锛岄�愬瓧鎵ц锛夛細
+     * eventType 蹇呴』鍦� {@link AuditConsts#VALID_TYPES} 鍐咃紱
+     * operatorId/appName/actionCode/eventTime/tenantId 浠讳竴涓虹┖鍒欐嫆缁濓紱
+     * clientEventId 涓虹┖鍒欐嫆缁濓紙DB NOT NULL + UNIQUE 骞傜瓑閿紝null 鏃犲箓绛夎涔夛級锛�
+     * sourceLayer 涓� null 鍒欐嫆缁濓紙DB NOT NULL 鍒楀嵆蹇呭~浜嬪疄婧愶紝鏃╂姏鍙寮傚父浼樹簬 DB 灞傝8鎶涳紱
+     * 鍙栧�艰寖鍥� 0-3 鐣欑粰 DB CHECK 绾︽潫鏍¢獙锛屼笉鍦ㄦ閲嶅锛夈��
+     *
+     * <p><b>鏍囪瘑绫诲瓧娈佃秴闀夸竴寰嬫嫆缁濄�佷笉鎴柇</b>锛圕1 璇勫 I-5锛夛細{@code clientEventId} 鏄箓绛夐敭锛�
+     * 鎴柇浼氳涓や釜涓嶅悓浜嬩欢濉屾垚鍚屼竴涓敭銆佽 ON CONFLICT 褰撴垚閲嶅鑰屼涪寮冿紱{@code operationId} 鎴柇
+     * 浼氭妸鏈笉鐩稿共鐨勬搷浣滄姌鍙犲埌涓�缁勩�傝繖涓よ�呮埅鏂�犳垚鐨勯敊璇瘮鎷掔粷鏇撮殣钄斤紝鏁呭畞鍙嫆缁濃�斺��
+     * 鑰屼笖瀹冧滑閮界敱 SDK/鏈嶅姟鑷繁鏋勯�狅紙灞� 0 鏄� "L0-"+闆姳=22 瀛楃锛夛紝瓒呴暱鏈韩灏辫鏄庤皟鐢ㄦ柟鏈夐棶棰樸��
+     * 鎻忚堪绫诲瓧娈靛垯鐩稿弽锛岃 {@link #toEntity}銆�
+     */
+    private void validate(AuditEventDTO dto) {
+        if (isBlank(dto.getClientEventId())) {
+            throw new IllegalArgumentException("clientEventId 涓嶈兘涓虹┖");
+        }
+        if (dto.getClientEventId().length() > LEN_CLIENT_EVENT_ID) {
+            throw new IllegalArgumentException("clientEventId 瓒呰繃 " + LEN_CLIENT_EVENT_ID
+                    + " 瀛楃锛屾埅鏂細鐮村潖骞傜瓑璇箟锛屾嫆缁濆叆搴�: " + dto.getClientEventId());
+        }
+        if (dto.getOperationId() != null && dto.getOperationId().length() > LEN_OPERATION_ID) {
+            throw new IllegalArgumentException("operationId 瓒呰繃 " + LEN_OPERATION_ID
+                    + " 瀛楃锛屾埅鏂細鐮村潖鎿嶄綔鍏宠仈璇箟锛屾嫆缁濆叆搴�: " + dto.getOperationId());
+        }
+        if (dto.getEventType() == null || !AuditConsts.VALID_TYPES.contains(dto.getEventType())) {
+            throw new IllegalArgumentException("eventType 涓嶅湪鍙楁帶鏋氫妇 VALID_TYPES 鍐�: " + dto.getEventType());
+        }
+        if (isBlank(dto.getOperatorId())) {
+            throw new IllegalArgumentException("operatorId 涓嶈兘涓虹┖");
+        }
+        if (isBlank(dto.getAppName())) {
+            throw new IllegalArgumentException("appName 涓嶈兘涓虹┖");
+        }
+        if (isBlank(dto.getActionCode())) {
+            throw new IllegalArgumentException("actionCode 涓嶈兘涓虹┖");
+        }
+        if (dto.getEventTime() == null) {
+            throw new IllegalArgumentException("eventTime 涓嶈兘涓虹┖");
+        }
+        if (isBlank(dto.getTenantId())) {
+            throw new IllegalArgumentException("tenantId 涓嶈兘涓虹┖");
+        }
+        if (dto.getSourceLayer() == null) {
+            throw new IllegalArgumentException("sourceLayer 涓嶈兘涓虹┖");
+        }
+    }
+
+    /**
+     * DTO 鈫� 瀹炰綋锛�**鎻忚堪绫� varchar 鍒楁寜鍒楀鎴柇**锛圕1 璇勫 I-5锛夈��
+     *
+     * <p>涓嶆埅鏂細鎬庢牱锛歿@code biz_code} 鎸夎璁$洿鍙栦笟鍔″師濮嬪�硷紙灞� 0 鍙栬〃鍗曞瓧娈点�佸眰 2 鍙� SpEL 姹傚�硷級锛�
+     * {@code browser}/{@code os}/{@code request_uri} 鍒欑敱瀹㈡埛绔� header 鍐冲畾鈥斺�斾换涓�瓒呴暱锛孭G 鎶�
+     * {@code 22001 value too long}锛岃�� {@code ON CONFLICT} 鍦ㄥ啿绐佸垽瀹氫箣鍓嶅氨宸茬粡鎶涗簡銆傚紓甯稿啋娉″洖
+     * 閲囬泦渚ц {@code catch(Throwable)} 鍏滀綇锛堝眰 0 鐨� L002 闃插尽銆佸眰 1 鐨勬�诲厹搴曢兘濡傛锛夛紝缁撴灉鏄�
+     * **鏁存潯瀹¤浜嬩欢姘镐箙娑堝け锛屽彧鍓╀竴琛� error 鏃ュ織**銆�
+     *
+     * <p>鏇磋璀︽儠鐨勬槸**瀹冧笉鑷剤**锛歴pec 搂6 灞� 0 鐨勫閿欏墠鎻愭槸銆岃繖娆″け璐ヤ簡锛屼笅娆′簨浠朵細鑷妇琛ヤ笂銆嶏紝
+     * 浣嗗瓧娈佃嫢鏄�**闀挎湡**瓒呴暱锛堟煇绫诲崟鎹殑缂栧彿澶╃敓灏遍暱锛夛紝璇ヨ鐨勬瘡涓�鏉′簨浠堕兘浼氫涪锛屾案杩滆ˉ涓嶅洖鏉モ�斺��
+     * 鐩存帴绐佺牬浜� spec 澹版槑鐨勫彲闈犳�у寘缁溿��
+     *
+     * <p><b>鎴柇蹇呴』鐣欑棔</b>锛堜笌 spec 搂8 杩佺Щ鑴氭湰銆岀闈欓粯鎴柇銆嶅悓涓�鏉$邯寰嬶級锛氳鎴柇鐨勫垪鍚嶈杩�
+     * {@code extra.truncatedFields}銆傚璁″彲浠ヨ寰椾笉鍏紝浣嗕笉鑳借浜轰互涓鸿鍏ㄤ簡銆�
+     */
+    private AuditEventEntity toEntity(AuditEventDTO dto) {
+        AuditEventEntity entity = new AuditEventEntity();
+        List<String> truncated = new ArrayList<>();
+        // 鏍囪瘑绫伙細宸插湪 validate 閲屽崱姝婚暱搴︼紝杩欓噷鍘熸牱鎼繍
+        entity.setClientEventId(dto.getClientEventId());
+        entity.setOperationId(dto.getOperationId());
+        entity.setEventTime(dto.getEventTime());
+        entity.setTenantId(clip(dto.getTenantId(), LEN_TENANT_ID, "tenant_id", truncated));
+        entity.setOperatorId(clip(dto.getOperatorId(), LEN_OPERATOR_ID, "operator_id", truncated));
+        entity.setOperatorName(clip(dto.getOperatorName(), LEN_OPERATOR_NAME, "operator_name", truncated));
+        entity.setOperatorOrgId(clip(dto.getOperatorOrgId(), LEN_OPERATOR_ORG_ID, "operator_org_id", truncated));
+        entity.setAppName(clip(dto.getAppName(), LEN_APP_NAME, "app_name", truncated));
+        entity.setBizModule(clip(dto.getBizModule(), LEN_BIZ_MODULE, "biz_module", truncated));
+        entity.setIp(clip(dto.getIp(), LEN_IP, "ip", truncated));
+        entity.setIpRegion(clip(dto.getIpRegion(), LEN_IP_REGION, "ip_region", truncated));
+        entity.setBrowser(clip(dto.getBrowser(), LEN_BROWSER, "browser", truncated));
+        entity.setOs(clip(dto.getOs(), LEN_OS, "os", truncated));
+        entity.setRequestUri(clip(dto.getRequestUri(), LEN_REQUEST_URI, "request_uri", truncated));
+        entity.setEventType(dto.getEventType());     // 鍙楁帶鏋氫妇锛寁alidate 宸叉尅
+        entity.setActionCode(clip(dto.getActionCode(), LEN_ACTION_CODE, "action_code", truncated));
+        entity.setActionLabel(clip(dto.getActionLabel(), LEN_ACTION_LABEL, "action_label", truncated));
+        entity.setSourceLayer(dto.getSourceLayer());
+        entity.setTargetTable(clip(dto.getTargetTable(), LEN_TARGET_TABLE, "target_table", truncated));
+        entity.setTargetId(clip(dto.getTargetId(), LEN_TARGET_ID, "target_id", truncated));
+        entity.setBizType(clip(dto.getBizType(), LEN_BIZ_TYPE, "biz_type", truncated));
+        entity.setBizCode(clip(dto.getBizCode(), LEN_BIZ_CODE, "biz_code", truncated));
+        // 浠ヤ笅閮芥槸 text 鍒楋紝鏃犻暱搴︾害鏉�
+        entity.setFieldDiffs(dto.getFieldDiffs());
+        entity.setDataSnapshot(dto.getDataSnapshot());
+        entity.setReason(dto.getReason());
+        entity.setRecordTitle(clip(dto.getRecordTitle(), LEN_RECORD_TITLE, "record_title", truncated));
+        entity.setEntryType(clip(dto.getEntryType(), LEN_ENTRY_TYPE, "entry_type", truncated));
+        entity.setEntryId(clip(dto.getEntryId(), LEN_ENTRY_ID, "entry_id", truncated));
+        entity.setEntryName(clip(dto.getEntryName(), LEN_ENTRY_NAME, "entry_name", truncated));
+        entity.setPrevHash(clip(dto.getPrevHash(), LEN_PREV_HASH, "prev_hash", truncated));
+        entity.setExtra(truncated.isEmpty() ? dto.getExtra() : noteTruncation(dto.getExtra(), truncated));
+        return entity;
+    }
+
+    /**
+     * 鍒楀甯搁噺锛屼笌 {@code audit_events} 鐨� DDL 閫愬垪瀵归綈锛圥G-only锛岃 spec 搂4 琛ㄥ畾涔夛級銆�
+     * DDL 鑻ユ斁瀹斤紝杩欓噷鍙槸鎴緱鏃╀竴鐐癸紙淇濆畧銆佷笉鑷村懡锛夛紱DDL 鑻ユ敹绐勶紝蹇呴』鍚屾鏀硅繖閲屻��
+     */
+    private static final int LEN_CLIENT_EVENT_ID = 70;
+    private static final int LEN_OPERATION_ID    = 64;
+    private static final int LEN_TENANT_ID       = 64;
+    private static final int LEN_OPERATOR_ID     = 64;
+    private static final int LEN_OPERATOR_NAME   = 100;
+    private static final int LEN_OPERATOR_ORG_ID = 64;
+    private static final int LEN_APP_NAME        = 50;
+    private static final int LEN_BIZ_MODULE      = 100;
+    private static final int LEN_IP              = 50;
+    private static final int LEN_IP_REGION       = 100;
+    private static final int LEN_BROWSER         = 100;
+    private static final int LEN_OS              = 100;
+    private static final int LEN_REQUEST_URI     = 500;
+    private static final int LEN_ACTION_CODE     = 50;
+    private static final int LEN_ACTION_LABEL    = 200;
+    private static final int LEN_TARGET_TABLE    = 100;
+    private static final int LEN_TARGET_ID       = 64;
+    private static final int LEN_BIZ_TYPE        = 50;
+    private static final int LEN_BIZ_CODE        = 100;
+    private static final int LEN_RECORD_TITLE = 500;
+    private static final int LEN_ENTRY_TYPE   = 20;
+    private static final int LEN_ENTRY_ID     = 50;
+    private static final int LEN_ENTRY_NAME   = 200;
+    private static final int LEN_PREV_HASH       = 64;
+
+    /** 瓒呴暱鍒欐埅鏂苟鎶婂垪鍚嶈杩� {@code truncated}锛涘惁鍒欏師鏍疯繑鍥炪�� */
+    private static String clip(String value, int max, String column, List<String> truncated) {
+        if (value == null || value.length() <= max) {
+            return value;
+        }
+        truncated.add(column);
+        log.warn("[audit] {} 瓒呴暱锛坽} > {}锛夛紝宸叉埅鏂叆搴撳苟璁板叆 extra.truncatedFields", column, value.length(), max);
+        return value.substring(0, max);
+    }
+
+    /**
+     * 鎶婃埅鏂褰曞苟杩� {@code extra}銆�
+     *
+     * <p>鍘� {@code extra} 涓嶆槸鍚堟硶 JSON 鏃�**涓嶈兘灏辫繖涔堜涪鎺夌暀鐥�**锛堥偅鍙堝彉鍥為潤榛樻埅鏂級锛�
+     * 鏀逛负鍖呬竴灞傚苟鎶婂師鏂囧畬鏁翠繚鐣欏湪 {@code originalExtra} 閲屸�斺�攞@code extra} 鏄� text 鍒楋紝瑁呭緱涓嬨��
+     */
+    private String noteTruncation(String extra, List<String> truncated) {
+        Map<String, Object> merged = null;
+        if (extra != null && !extra.trim().isEmpty()) {
+            try {
+                merged = JsonUtil.stringToMap(extra);
+            } catch (Throwable ignore) {
+                merged = null;
+            }
+        }
+        if (merged == null) {
+            merged = new LinkedHashMap<>();
+            if (extra != null && !extra.trim().isEmpty()) {
+                merged.put("originalExtra", extra);   // 鍘熸枃涓嶆槸 JSON锛屽師鏍峰厹浣忥紝涓嶄涪淇℃伅
+            }
+        }
+        merged.put("truncatedFields", truncated);
+        return JsonUtil.getObjectToString(merged);
+    }
+
+    private static boolean isBlank(String s) {
+        return s == null || s.trim().isEmpty();
+    }
+}

--
Gitblit v1.8.0