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-audit-sdk/src/main/java/jnpf/audit/sdk/aspect/AuditLogAspect.java |  502 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 502 insertions(+), 0 deletions(-)

diff --git a/jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/aspect/AuditLogAspect.java b/jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/aspect/AuditLogAspect.java
new file mode 100644
index 0000000..816669e
--- /dev/null
+++ b/jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/aspect/AuditLogAspect.java
@@ -0,0 +1,502 @@
+package jnpf.audit.sdk.aspect;
+
+import com.alibaba.fastjson.JSON;
+import jnpf.audit.diff.AuditFieldDiff;
+import jnpf.audit.AuditOperationIds;
+import jnpf.audit.model.AuditEventDTO;
+import jnpf.audit.sdk.AuditClient;
+import jnpf.audit.sdk.AuditEventPublisher;
+import jnpf.audit.sdk.AuditRequestCorrelationResolver;
+import jnpf.audit.sdk.AuditTxHolder;
+import jnpf.audit.sdk.annotation.AuditLog;
+import jnpf.util.JsonUtil;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.expression.MethodBasedEvaluationContext;
+import org.springframework.core.DefaultParameterNameDiscoverer;
+import org.springframework.core.ParameterNameDiscoverer;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.core.annotation.Order;
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.ExpressionParser;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+
+import java.io.Serializable;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 灞� 2 鍒囬潰銆�
+ *
+ * <p><b>@Order(50) 鏄‖绾︽潫</b>锛氬繀椤诲皬浜� lims 鏃� {@code BizLogAspect} 鐨� {@code @Order(100)}锛�
+ * 浣挎湰鍒囬潰鍖呭湪鏃у垏闈�**澶栧眰**鈥斺�斿彧鏈夎繖鏍凤紝鏈垏闈㈠湪 proceed() 涔嬪墠鍐欏叆 MDC 鐨� operationId锛�
+ * 鍐呭眰鏃у垏闈㈡墠璇诲緱鍒板苟鍐欒繘 extra_json锛孉/B 鎵嶈兘鎸� operation_id 绮剧‘ join銆傞『搴忓啓鍙� = 閰嶅鍏ㄧ┖銆�
+ *
+ * <p>operationId 涓�寰嬬粡 {@link AuditTxHolder#beginOperation} 鍙栵紝**鍒囬潰涓嶅緱鑷鐢熸垚**锛堝崟婧愬绾︼級銆�
+ */
+@Aspect
+@Order(50)
+public class AuditLogAspect {
+
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AuditLogAspect.class);
+
+    private final AuditClient auditClient;
+    private final AuditEventPublisher publisher;
+    private final ApplicationContext applicationContext;
+    private final Map<Class<?>, Object> mapperCache = new ConcurrentHashMap<>();
+    private final ExpressionParser parser = new SpelExpressionParser();
+    private final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();
+
+    public AuditLogAspect(AuditClient auditClient, AuditEventPublisher publisher,
+                          ApplicationContext applicationContext) {
+        this.auditClient = auditClient;
+        this.publisher = publisher;
+        this.applicationContext = applicationContext;
+    }
+
+    @Pointcut("@annotation(jnpf.audit.sdk.annotation.AuditLog)")
+    public void auditLogPointcut() {}
+
+    @Around("auditLogPointcut()")
+    public Object around(ProceedingJoinPoint pjp) throws Throwable {
+        MethodSignature signature = (MethodSignature) pjp.getSignature();
+        Method method = signature.getMethod();
+        AuditLog ann = AnnotationUtils.findAnnotation(method, AuditLog.class);
+        if (ann == null) {
+            return pjp.proceed();
+        }
+
+        // 杩涘叆鍗崇‘淇� operationId 瀛樺湪骞跺啓鍏� MDC锛堝繀椤诲湪 proceed 涔嬪墠鈥斺�斿唴灞傛棫鍒囬潰瑕佽锛�
+        String correlatedOperationId = AuditOperationIds.correlation(
+                AuditRequestCorrelationResolver.currentBizSign());
+        AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(publisher, correlatedOperationId);
+        try {
+            DiffCapture diffCapture = captureBefore(ann, method, pjp.getArgs());
+            Object result;
+            try {
+                result = pjp.proceed();
+            } catch (Throwable biz) {
+                // 澶辫触鐣欑棔锛氬彧鍦ㄦ樉寮忓0鏄� failureAction 鏃惰褰曪紙榛樿鐣欑┖ = M2 鍘熻涓轰笉鍙橈級銆�
+                // 涓夋潯纭害鏉燂細鈶� 璁板綍澶辫触缁濅笉鑳芥敼鍙樹笟鍔″紓甯哥殑浼犳挱鈥斺�斿師鏍� rethrow锛屼笉鍖呰涓嶅悶锛�
+                // 鈶� 瀹¤鑷韩鍑洪敊涔熶笉鑳藉奖鍝嶄笟鍔★紝鏁呭唴灞傚啀濂椾竴灞� catch锛涒憿 涓氬姟寮傚父鍏堜簬瀹¤鍙戠敓锛�
+                // 浜嬪姟寰堝彲鑳藉凡鏍囪鍥炴粴锛屾墍浠ュけ璐ヤ簨浠惰蛋鐨勪粛鏄� holder 缂撳啿閭f潯璺紙涓庢垚鍔熻矾寰勪竴鑷达級锛�
+                // 鐢� SDK 鍐冲畾鏈�缁堟姇閫掓柟寮忥紝鍒囬潰涓嶈嚜浣滀富寮犵洿鍙戙��
+                if (!ann.failureAction().isEmpty()) {
+                    try {
+                        record(ann, method, pjp.getArgs(), null, scope.operationId(), biz, diffCapture);
+                    } catch (Throwable t) {
+                        log.error("[AuditLog] 璁板綍澶辫触浜嬩欢鏃惰嚜韬嚭閿�: method={}, failureAction={}",
+                                method.getName(), ann.failureAction(), t);
+                    }
+                }
+                throw biz;
+            }
+            // recordSuccess=false = 鎴愬姛浜嬪疄鐢卞埆澶勮锛堝綋鍓嶅彧鏈夌數瀛愮鍚嶏紝瑙佹敞瑙� javadoc锛夈��
+            // 鍙叧鎴愬姛渚ц繖涓�鎵囬棬锛氫笂闈㈠け璐ヤ晶鐨� failureAction 鍒嗘敮瀹屽叏涓嶅彈褰卞搷銆�
+            if (ann.recordSuccess()) {
+                try {
+                    record(ann, method, pjp.getArgs(), result, scope.operationId(), null, diffCapture);
+                } catch (Throwable t) {
+                    // 瀹¤鍐欏叆涓嶅緱褰卞搷涓氬姟杩斿洖锛堜笌鏃� BizLogAspect 鍚屾鎶ゆ爮锛�
+                    log.error("[AuditLog] 璁板綍瀹¤澶辫触: method={}, action={}", method.getName(), ann.action(), t);
+                }
+            }
+            return result;
+        } finally {
+            scope.close();
+        }
+    }
+
+    /**
+     * @param failure 闈� null 琛ㄧず杩欐槸涓�鏉�**澶辫触浜嬩欢**锛氬姩浣滅爜鍙� {@code failureAction}銆�
+     *                寮傚父 message 钀� {@code reason}锛屼笖 {@code #result} 鍦� SpEL 閲屾亽涓� null
+     */
+    private void record(AuditLog ann, Method method, Object[] args, Object result,
+                        String operationId, Throwable failure, DiffCapture diffCapture) {
+        boolean failed = failure != null;
+        EvaluationContext ctx = buildContext(method, args, result);
+        String bizCode = evalString(ann.bizCodeExpr(), ctx, method, "bizCodeExpr");
+        if (bizCode == null || bizCode.isEmpty()) {
+            if (ann.bizCodeRequired()) {
+                log.warn("[AuditLog] {} 鐨� bizCodeExpr={} 姹傚�间负绌猴紝璺宠繃", method.getName(), ann.bizCodeExpr());
+                return;
+            }
+            // bizCodeRequired=false锛氱暀绌虹収璁帮紙瀹¤绾㈢嚎鏄�"杩欐鎿嶄綔鍙戠敓杩�"涓嶈兘缂猴紝瑙佹敞瑙� javadoc锛�
+            bizCode = null;
+        }
+        String actionCode = failed ? ann.failureAction() : ann.action();
+        String actionLabel = failed
+                ? (ann.failureLabel().isEmpty() ? actionCode : ann.failureLabel())
+                : (ann.label().isEmpty() ? actionCode : ann.label());
+        AuditEventDTO dto = AuditEventDTO.builder()
+                .operationId(operationId)
+                .eventType(ann.eventType())
+                .actionCode(actionCode)
+                .actionLabel(actionLabel)
+                .bizType(ann.bizType().isEmpty() ? null : ann.bizType())
+                .bizCode(bizCode)
+                .targetTable(ann.targetTable().isEmpty() ? null : ann.targetTable())
+                .targetId(resolveTargetId(ann, ctx, method, diffCapture))
+                .sourceLayer(2)
+                .build();
+        // reason 鍒� = 鎿嶄綔鑰呴檲杩扮殑涓氬姟鍘熷洜锛堝绛惧悕 note锛夛紝鎴愬姛澶辫触閮藉~銆佽涔変笉闅忔垚璐ュ彉
+        dto.setReason(evalString(ann.reasonExpr(), ctx, method, "reasonExpr"));
+
+        if (!failed && ann.diff()) {
+            populateDiff(dto, ann, method, ctx, diffCapture);
+        }
+        String requestTitle = AuditRequestTitleResolver.resolveCurrentRequest();
+        dto.setRecordTitle(requestTitle == null ? dto.getTargetId() : requestTitle);
+
+        Map<String, Object> extra = new LinkedHashMap<>();
+        if (!ann.extraExpr().isEmpty()) {
+            // 澶辫触璺緞涓� extraExpr 鑻ュ紩鐢� #result 浼氭眰鍊煎け璐モ�斺�斿彧涓� extra 涓嶄涪鏁存潯浜嬩欢
+            try {
+                Object extraObj = parser.parseExpression(ann.extraExpr()).getValue(ctx);
+                if (extraObj instanceof Map) {
+                    // 鎷疯繘鍙彉 map锛歋pEL 瀛楅潰閲� map 鐨勫彲鍙樻�ф病鏈夊绾︿繚璇侊紝鑰屼笅闈㈣ put failureReason
+                    ((Map<?, ?>) extraObj).forEach((k, v) -> extra.put(String.valueOf(k), v));
+                }
+            } catch (RuntimeException e) {
+                log.warn("[AuditLog] {} 鐨� extraExpr={} 姹傚�煎け璐ワ紙failed={}锛夛紝鏈潯浜嬩欢涓嶅甫 extra: {}",
+                        method.getName(), ann.extraExpr(), failed, e.getMessage());
+            }
+        }
+        if (failed) {
+            // 澶辫触鐨�**鎶�鏈�**鍘熷洜褰� extra锛屼笉鍗� reason 鍒楋紙瑙� @AuditLog#reasonExpr 鐨勮涔夌害瀹氾級
+            extra.put("failureReason", failure.getMessage());
+            extra.put("failureType", failure.getClass().getSimpleName());
+        }
+        if (diffCapture != null && diffCapture.unavailable != null) {
+            extra.put("diffUnavailable", diffCapture.unavailable);
+        }
+        if (diffCapture != null && !diffCapture.unavailableTargetIds.isEmpty()) {
+            extra.put("diffUnavailableTargetIds", new ArrayList<>(diffCapture.unavailableTargetIds));
+        }
+        if (!extra.isEmpty()) {
+            dto.setExtra(JsonUtil.getObjectToString(extra));   // String 瀛� JSON 鈫� 钀� text 鍒楋紙L043锛�
+        }
+        auditClient.record(dto);   // 鍐呴儴缁� collector 濉綈鍏繀濉� + tenant 鍥炶惤锛屽啀浜� holder 缂撳啿
+    }
+
+    private DiffCapture captureBefore(AuditLog ann, Method method, Object[] args) {
+        if (!ann.diff()) {
+            return null;
+        }
+        DiffCapture capture = new DiffCapture();
+        if (ann.entityClass() == Void.class
+                || (ann.targetIdExpr().isEmpty() && ann.targetIdsExpr().isEmpty())) {
+            capture.unavailable = "diff=true requires entityClass and targetIdExpr/targetIdsExpr";
+            return capture;
+        }
+        try {
+            if (!ann.targetIdsExpr().isEmpty()) {
+                capture.entityIds = evalSerializableIds(
+                        ann.targetIdsExpr(), buildContext(method, args, null));
+                for (Serializable entityId : capture.entityIds) {
+                    try {
+                        capture.batchBefore.put(entityId, selectAsMap(ann.entityClass(), entityId));
+                    } catch (Throwable t) {
+                        capture.unavailableTargetIds.add(String.valueOf(entityId));
+                        log.warn("[AuditLog] {} 鏌ヨ鎵归噺淇敼鍓嶆暟鎹け璐�: targetId={}, err={}",
+                                method.getName(), entityId, t.getMessage());
+                    }
+                }
+                return capture;
+            }
+            capture.entityId = evalSerializable(ann.targetIdExpr(), buildContext(method, args, null));
+            if (capture.entityId != null) {
+                capture.before = selectAsMap(ann.entityClass(), capture.entityId);
+            }
+        } catch (Throwable t) {
+            capture.unavailable = t.getMessage();
+            log.warn("[AuditLog] {} 鏌ヨ淇敼鍓嶆暟鎹け璐�: {}", method.getName(), t.getMessage());
+        }
+        return capture;
+    }
+
+    private void populateDiff(AuditEventDTO dto, AuditLog ann, Method method,
+                              EvaluationContext ctx, DiffCapture capture) {
+        if (capture == null) {
+            return;
+        }
+        if (capture.unavailable != null) {
+            return;
+        }
+        try {
+            if (!capture.entityIds.isEmpty()) {
+                populateBatchDiff(dto, ann, method, capture);
+                return;
+            }
+            Serializable entityId = capture.entityId;
+            if (entityId == null) {
+                entityId = evalSerializable(ann.targetIdExpr(), ctx);
+            }
+            Map<String, Object> after = entityId == null ? null : selectAsMap(ann.entityClass(), entityId);
+            dto.setFieldDiffs(AuditFieldDiff.diff(capture.before, after, Collections.emptySet()));
+            dto.setDataSnapshot(AuditFieldDiff.snapshot(
+                    "DELETE".equalsIgnoreCase(ann.action()) ? capture.before : after,
+                    Collections.emptySet()));
+        } catch (Throwable t) {
+            capture.unavailable = t.getMessage();
+            log.warn("[AuditLog] {} 鏌ヨ淇敼鍚庢暟鎹垨鐢熸垚 diff 澶辫触: {}", method.getName(), t.getMessage());
+        }
+    }
+
+    private void populateBatchDiff(AuditEventDTO dto, AuditLog ann, Method method,
+                                   DiffCapture capture) {
+        Map<Serializable, Map<String, Object>> afterById = new LinkedHashMap<>();
+        for (Serializable entityId : capture.entityIds) {
+            if (capture.unavailableTargetIds.contains(String.valueOf(entityId))) {
+                continue;
+            }
+            try {
+                afterById.put(entityId, selectAsMap(ann.entityClass(), entityId));
+            } catch (Throwable t) {
+                capture.unavailableTargetIds.add(String.valueOf(entityId));
+                log.warn("[AuditLog] {} 鏌ヨ鎵归噺淇敼鍚庢暟鎹け璐�: targetId={}, err={}",
+                        method.getName(), entityId, t.getMessage());
+            }
+        }
+        boolean delete = "DELETE".equalsIgnoreCase(ann.action());
+        dto.setFieldDiffs(batchFieldDiffs(
+                ann.targetTable(), capture.entityIds, capture.batchBefore, afterById));
+        dto.setDataSnapshot(batchSnapshot(
+                capture.entityIds, delete ? capture.batchBefore : afterById));
+    }
+
+    static String batchFieldDiffs(String targetTable,
+                                  List<Serializable> entityIds,
+                                  Map<Serializable, Map<String, Object>> beforeById,
+                                  Map<Serializable, Map<String, Object>> afterById) {
+        List<Map<String, Object>> combined = new ArrayList<>();
+        for (Serializable entityId : entityIds) {
+            if (!beforeById.containsKey(entityId) || !afterById.containsKey(entityId)) {
+                continue;
+            }
+            String json = AuditFieldDiff.diff(
+                    beforeById.get(entityId), afterById.get(entityId), Collections.emptySet());
+            List<Map> items = JSON.parseArray(json, Map.class);
+            for (Map item : items) {
+                Map<String, Object> scoped = new LinkedHashMap<>();
+                scoped.put("targetTable", targetTable);
+                scoped.put("targetId", String.valueOf(entityId));
+                scoped.putAll(item);
+                combined.add(scoped);
+            }
+        }
+        return JSON.toJSONString(combined);
+    }
+
+    static String batchSnapshot(List<Serializable> entityIds,
+                                Map<Serializable, Map<String, Object>> rowsById) {
+        List<Map<String, Object>> snapshots = new ArrayList<>();
+        for (Serializable entityId : entityIds) {
+            if (!rowsById.containsKey(entityId)) {
+                continue;
+            }
+            Map<String, Object> snapshot = new LinkedHashMap<>();
+            snapshot.put("targetId", String.valueOf(entityId));
+            String data = AuditFieldDiff.snapshot(rowsById.get(entityId), Collections.emptySet());
+            snapshot.put("data", data == null ? null : JSON.parseObject(data, Map.class));
+            snapshots.add(snapshot);
+        }
+        return JSON.toJSONString(snapshots);
+    }
+
+    private Serializable evalSerializable(String expr, EvaluationContext ctx) {
+        Object value = parser.parseExpression(expr).getValue(ctx);
+        return value instanceof Serializable ? (Serializable) value : null;
+    }
+
+    private List<Serializable> evalSerializableIds(String expr, EvaluationContext ctx) {
+        return normalizeSerializableIds(parser.parseExpression(expr).getValue(ctx));
+    }
+
+    static List<Serializable> normalizeSerializableIds(Object value) {
+        LinkedHashSet<Serializable> ids = new LinkedHashSet<>();
+        if (value == null) {
+            return new ArrayList<>();
+        }
+        if (value instanceof Collection) {
+            for (Object item : (Collection<?>) value) {
+                addSerializableId(ids, item);
+            }
+        } else if (value instanceof Iterable) {
+            for (Object item : (Iterable<?>) value) {
+                addSerializableId(ids, item);
+            }
+        } else if (value.getClass().isArray()) {
+            for (int i = 0; i < Array.getLength(value); i++) {
+                addSerializableId(ids, Array.get(value, i));
+            }
+        } else if (value instanceof CharSequence && String.valueOf(value).contains(",")) {
+            for (String item : String.valueOf(value).split(",")) {
+                addSerializableId(ids, item);
+            }
+        } else {
+            addSerializableId(ids, value);
+        }
+        return new ArrayList<>(ids);
+    }
+
+    private static void addSerializableId(LinkedHashSet<Serializable> ids, Object value) {
+        if (!(value instanceof Serializable)) {
+            return;
+        }
+        if (value instanceof CharSequence) {
+            String text = String.valueOf(value).trim();
+            if (!text.isEmpty()) {
+                ids.add(text);
+            }
+            return;
+        }
+        ids.add((Serializable) value);
+    }
+
+    private Map<String, Object> selectAsMap(Class<?> entityClass, Serializable id) throws Exception {
+        Object mapper = findMapperFor(entityClass);
+        Method selectById = null;
+        for (Method candidate : mapper.getClass().getMethods()) {
+            if ("selectById".equals(candidate.getName()) && candidate.getParameterTypes().length == 1) {
+                selectById = candidate;
+                break;
+            }
+        }
+        if (selectById == null) {
+            throw new IllegalStateException("Mapper does not expose selectById: " + mapper.getClass().getName());
+        }
+        Object entity = selectById.invoke(mapper, id);
+        return entity == null ? null : entityToColumnMap(entity);
+    }
+
+    private Object findMapperFor(Class<?> entityClass) {
+        Object cached = mapperCache.get(entityClass);
+        if (cached != null) {
+            return cached;
+        }
+        String simpleName = entityClass.getSimpleName();
+        if (!simpleName.endsWith("Entity")) {
+            throw new IllegalArgumentException("entityClass must end with Entity: " + simpleName);
+        }
+        String mapperName = Character.toLowerCase(simpleName.charAt(0))
+                + simpleName.substring(1, simpleName.length() - "Entity".length()) + "Mapper";
+        Object mapper = applicationContext.getBean(mapperName);
+        mapperCache.put(entityClass, mapper);
+        return mapper;
+    }
+
+    /** 灏嗗疄浣撳睘鎬ф寜 MyBatis-Plus 娉ㄨВ杞崲涓虹湡瀹炴暟鎹簱鍒楀悕锛屼緵鏌ヨ渚у尮閰嶅垪澶囨敞銆� */
+    private static Map<String, Object> entityToColumnMap(Object entity) throws IllegalAccessException {
+        Map<String, Object> result = new LinkedHashMap<>();
+        for (Class<?> type = entity.getClass(); type != null && type != Object.class; type = type.getSuperclass()) {
+            for (Field field : type.getDeclaredFields()) {
+                if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) {
+                    continue;
+                }
+                String column = mappedColumn(field);
+                if (column == null) {
+                    continue;
+                }
+                field.setAccessible(true);
+                result.put(column, field.get(entity));
+            }
+        }
+        return result;
+    }
+
+    private static String mappedColumn(Field field) {
+        for (Annotation annotation : field.getAnnotations()) {
+            String annotationName = annotation.annotationType().getName();
+            if (!"com.baomidou.mybatisplus.annotation.TableField".equals(annotationName)
+                    && !"com.baomidou.mybatisplus.annotation.TableId".equals(annotationName)) {
+                continue;
+            }
+            try {
+                if ("com.baomidou.mybatisplus.annotation.TableField".equals(annotationName)) {
+                    Object exists = annotation.annotationType().getMethod("exist").invoke(annotation);
+                    if (Boolean.FALSE.equals(exists)) {
+                        return null;
+                    }
+                }
+                Object value = annotation.annotationType().getMethod("value").invoke(annotation);
+                if (value != null && !String.valueOf(value).trim().isEmpty()) {
+                    return String.valueOf(value).trim();
+                }
+            } catch (ReflectiveOperationException ignored) {
+                // 娉ㄨВ鐗堟湰宸紓鏃跺洖钀藉埌灞炴�у悕杞崲锛屼笉鑳借瀹¤褰卞搷涓氬姟銆�
+            }
+        }
+        return camelToSnake(field.getName());
+    }
+
+    private static String camelToSnake(String value) {
+        StringBuilder out = new StringBuilder(value.length() + 8);
+        for (int i = 0; i < value.length(); i++) {
+            char ch = value.charAt(i);
+            if (Character.isUpperCase(ch)) {
+                out.append('_').append(Character.toLowerCase(ch));
+            } else {
+                out.append(ch);
+            }
+        }
+        return out.toString();
+    }
+
+    private static final class DiffCapture {
+        private Serializable entityId;
+        private Map<String, Object> before;
+        private List<Serializable> entityIds = Collections.emptyList();
+        private final Map<Serializable, Map<String, Object>> batchBefore = new LinkedHashMap<>();
+        private final LinkedHashSet<String> unavailableTargetIds = new LinkedHashSet<>();
+        private String unavailable;
+    }
+
+    private EvaluationContext buildContext(Method method, Object[] args, Object result) {
+        MethodBasedEvaluationContext ctx = new MethodBasedEvaluationContext(new Object(), method, args, nameDiscoverer);
+        ctx.setVariable("result", result);
+        return ctx;
+    }
+
+    /**
+     * SpEL 姹傚�� + **閫愬瓧娈靛厹搴�**锛氭眰鍊兼姏寮傚父鍙涪杩欎釜瀛楁骞� WARN锛屼笉璁╂暣鏉′簨浠舵秷澶便��
+     *
+     * <p>涓轰粈涔堝繀椤诲厹搴曪細澶辫触浜嬩欢鐨勪笂涓嬫枃澶╃劧涓嶅畬鏁达紙{@code #result} 涓� null锛夛紝
+     * 鑰� {@code #result.id} 杩欑闈炲畨鍏ㄥ鑸湪 null 涓婂彇灞炴�т細鎶� {@code SpelEvaluationException}銆�
+     * 鑻ヤ笉鍏滃簳锛屼竴涓啓寰椾笉澶熻皑鎱庣殑琛ㄨ揪寮忓氨浼氳**澶辫触浜嬩欢鏁存潯娑堝け**鈥斺�旈偅姝f槸鏈�闇�瑕佺暀鐥曠殑鏃跺埢銆�
+     */
+    private String evalString(String expr, EvaluationContext ctx, Method method, String exprName) {
+        if (expr == null || expr.isEmpty()) return null;
+        try {
+            Object v = parser.parseExpression(expr).getValue(ctx);
+            return v == null ? null : v.toString();
+        } catch (RuntimeException e) {
+            log.warn("[AuditLog] {} 鐨� {}={} 姹傚�煎け璐ワ紝璇ュ瓧娈电暀绌�: {}",
+                    method.getName(), exprName, expr, e.getMessage());
+            return null;
+        }
+    }
+
+    private String resolveTargetId(AuditLog ann, EvaluationContext ctx, Method method,
+                                   DiffCapture capture) {
+        String targetId = evalString(ann.targetIdExpr(), ctx, method, "targetIdExpr");
+        if (targetId != null || capture == null || capture.entityIds.isEmpty()) {
+            return targetId;
+        }
+        return String.valueOf(capture.entityIds.get(0));
+    }
+}

--
Gitblit v1.8.0