刘光辉
昨天 bb638871a7fb692d80f1b7a758f991dc0879002c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
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,A/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) {
                // 失败留痕:只在显式声明 failureAction 时记录(默认留空 = M2 原行为不变)。
                // 三条硬约束:① 记录失败绝不能改变业务异常的传播——原样 rethrow,不包装不吞;
                // ② 审计自身出错也不能影响业务,故内层再套一层 catch;③ 业务异常先于审计发生,
                // 事务很可能已标记回滚,所以失败事件走的仍是 holder 缓冲那条路(与成功路径一致),
                // 由 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:SpEL 字面量 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}。
     * 若不兜底,一个写得不够谨慎的表达式就会让**失败事件整条消失**——那正是最需要留痕的时刻。
     */
    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));
    }
}