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));
|
}
|
}
|