审计组件

需求

安全隐私合规要求,比如欧洲GDPR等需要记录个人隐私信息, 敏感业务信息(电商的价格)查询和修改。
image
查询操作审计
• 记录查询的字段范围
• 记录返回的数据量
• 记录查询条件
• 不记录具体数据内容(避免日志膨胀)

修改操作审计
• 记录修改前后的完整值
• 记录修改原因
• 支持数据回滚
• 敏感数据可加密存储

删除操作审计
• 记录删除的完整数据
• 标记为软删除
• 保留恢复能力
• 记录删除审批流程

导出操作审计
• 记录导出的数据范围
• 记录导出格式和用途
• 记录文件下载次数
• 水印标记导出人信息

技术方案

方案一:AOP + 手动快照(推荐)

适合大多数业务场景,平衡了自动化和灵活性

• 使用AOP统一拦截需要审计的方法
• 在方法执行前查询原始数据
• 使用Jackson或Gson进行深拷贝和JSON序列化
• 敏感字段使用AES-256加密后存储
• 异步写入审计日志,避免影响主流程性能

方案二:JPA监听器(中小型项目)

适合使用JPA/Hibernate的项目,自动化程度高

• 利用Hibernate的LoadedState自动获取原始值
• 零业务代码侵入
• 需要注意N+1查询问题

方案三:CDC监听(大型系统)

适合微服务架构、需要捕获所有数据变更的场景

• 使用Debezium监听数据库binlog
• 通过ThreadLocal或消息头传递业务上下文
• 需要Kafka等消息中间件支持
为什么切Service层?

  • 业务上下文完整:Service层的方法(如updateOrderupdateUser)是业务操作的入口,参数通常是前端传递的DTO或领域对象,包含完整的修改信息,便于直接与数据库原始DO对比。
  • 切入点明确:Service层方法命名规范(如updateXXXmodifyXXX),容易通过AOP表达式拦截(如execution(* com.xxx.service.*.update*(..))),且能精准匹配“修改操作”。
  • 支持业务过滤:可在AOP中结合业务逻辑判断是否需要记录变更(如某些状态的订单不允许修改,可直接跳过对比)。

2. 为什么不建议切DAO层?

  • 缺乏业务上下文:DAO层方法(如saveupdate)通常接收的是DO,此时原始DO和修改后DO的差异可能已被Service层处理(如自动填充更新时间),导致对比结果包含非业务修改(如系统字段)。
  • 切入点模糊:DAO层的update方法可能被多个业务场景调用(如创建、修改、批量操作),难以区分“是否需要跟踪变更”。
  • 性能问题:DAO层方法可能被高频调用(如批量更新),在此切入会增加不必要的查询和对比开销。

记录变更日志的方案 - 半自动化方案

安全隐私合规要求,比如欧洲GDPR等需要记录个人隐私信息, 敏感业务信息(电商的价格)等。

方案1实现

用法
@AuditChange(daoClassName = "productDao",queryMethod="findById" businessKey = "id")
public void updateProduct(Product product) {
productRepository.save(product);
}

原理:通过round aop,在执行joinPoint.proceed();之前查询数据库,执行joinPoint.proceed(); 之后再查询数据库,
做查询前后对象对比。

不足:如果一个领域对象 对应多张表,这个实现无法满足。比如中台系统一个 履约订单领域对象,对应多张表(一张公共表+多张业务身份扩展表)
如果存在如果一个领域对象 对应多张表,业务系统自己实现。

package com.example.order.aop;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuditChange {
    String daoClassName ();
    String queryMethod ();
    String businessKey() default "id";  // 指定业务主键字段
}


package com.example.order.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;

@Aspect
@Component
public class ChangeAuditAspect {


    @Around("@annotation(auditChange)")
    public Object auditUpdate(ProceedingJoinPoint joinPoint,
                              AuditChange auditChange) throws Throwable {

        // 获取方法参数
        Object[] args = joinPoint.getArgs();
        Object entity = args[0];  // 假设第一个参数是实体对象

        // 1. 获取主键值
        String businessKey = auditChange.businessKey();
        Object id = getFieldValue(entity, businessKey);

        Object x = joinPoint.getTarget();


        // 2. 查询修改前的原始数据
//        Object oldEntity = queryOriginalData(
//                joinPoint.getTarget(),
//                id
//        );

        // 3. 执行修改操作
        Object result = joinPoint.proceed();

        // 4. 获取修改后的数据
//        Object newEntity = queryOriginalData(
//                joinPoint.getTarget(),
//                id
//        );


        return result;
    }


    /**
     * 从实体对象中获取指定字段的值(支持私有字段和继承的字段)
     *
     * @param entity    实体对象(不能为null)
     * @param fieldName 字段名(如"id"、"orderId",不能为null或空)
     * @return 字段的值(可能为null)
     * @throws IllegalArgumentException 当实体为null、字段名为空或字段不存在时抛出
     * @throws IllegalAccessException   当字段访问权限异常时抛出(理论上通过setAccessible可避免)
     */
    private Object getFieldValue(Object entity, String fieldName) throws IllegalArgumentException, IllegalAccessException {
        // 1. 校验参数
        if (entity == null) {
            throw new IllegalArgumentException("实体对象不能为null");
        }
        if (fieldName == null || fieldName.trim().isEmpty()) {
            throw new IllegalArgumentException("字段名不能为null或空");
        }

        // 2. 获取实体的Class对象(从当前类开始,向上查找父类)
        Class<?> currentClass = entity.getClass();
        Field targetField = null;

        // 3. 循环查找字段(包括父类)
        while (currentClass != null && targetField == null) {
            try {
                // 查找当前类中声明的字段(包括private)
                targetField = currentClass.getDeclaredField(fieldName);
            } catch (NoSuchFieldException e) {
                // 当前类没有该字段,继续查找父类
                currentClass = currentClass.getSuperclass();
            }
        }

        // 4. 检查字段是否存在
        if (targetField == null) {
            throw new IllegalArgumentException("实体类[" + entity.getClass().getName() + "]中不存在字段:" + fieldName);
        }

        // 5. 忽略访问权限检查(即使是private字段也能访问)
        targetField.setAccessible(true);

        // 6. 获取字段值并返回
        return targetField.get(entity);
    }


}
posted @ 2025-10-31 17:03  向着朝阳  阅读(16)  评论(0)    收藏  举报