架构实战第4篇:谁动了我的数据——MyBatis拦截器实现审计字段自动注入

📅 2026/7/29 9:58:44 👁️ 阅读次数 📝 编程学习
架构实战第4篇:谁动了我的数据——MyBatis拦截器实现审计字段自动注入

摘要:任何企业级系统都需要记录"谁创建了这条数据、谁最后修改了它"。如果让每个开发者在每次save/update时手动set这些字段,既繁琐又容易遗漏。本文结合《鹿鲸项目管理工具》的MyBatisInterceptor拦截器,展示如何用一个MyBatis插件实现审计字段的全自动注入——开发者无需写一行审计代码,系统自动填充创建人、修改人、创建时间、修改时间。

在前面的几篇文章中,我们分别讲解了

架构实战第1篇:构建统一全局响应封装,打造标准化接口契约

架构实战第2篇:项目中各种O(PO\BO\DTO\VO)的实践与思考

架构实战第3篇:三个文件消灭90%重复代码-泛型CRUD架构解析

今天我们主要讲解:MyBatis拦截器实现审计字段自动注入

一、问题:审计字段手动填充有多痛?

1.1 审计字段是刚需

几乎所有业务表都有这几个字段:

字段

说明

示例

create_user_id

创建人ID

"a1b2c3..."

create_user_name

创建人姓名

"张三"

created_time

创建时间

2026-06-17 10:30:00

modify_user_id

修改人ID

"d4e5f6..."

modify_user_name

修改人姓名

"李四"

modified_time

修改时间

2026-06-17 14:20:00

它们用于数据追溯、操作审计、权限控制,是系统的"黑匣子记录仪"。

1.2 传统做法的痛苦

// ❌ 传统做法:每个Service都要手动填充 @Service public class UserServiceImpl { public boolean addUser(AddUserDTO dto) { UserPO userPO = new UserPO(); userPO.setUserName(dto.getUserName()); // 手动填充审计字段... userPO.setCreateUserId(SecurityUtil.getLoginId()); userPO.setCreateUserName(SecurityUtil.getLoginUser().getUserName()); userPO.setCreatedTime(new Date()); return userMapper.insert(userPO) > 0; } public boolean updateUser(ModifyUserDTO dto) { UserPO userPO = userMapper.selectById(dto.getId()); userPO.setUserName(dto.getUserName()); // 又要手动填充... userPO.setModifyUserId(SecurityUtil.getLoginId()); userPO.setModifyUserName(SecurityUtil.getLoginUser().getUserName()); userPO.setModifiedTime(new Date()); return userMapper.updateById(userPO) > 0; } }

1.3 四大问题

问题

后果

代码重复

50个实体 × 2个方法 = 100处手动填充代码

容易遗漏

新人忘了写setModifyUserId,数据追溯链断裂

侵入业务

审计逻辑混入业务代码,可读性差

维护困难

如果审计字段名变更,需要全局搜索修改

二、鹿鲸方案:MyBatis拦截器自动注入

2.1 方案全貌

开发者代码 MyBatis拦截器 数据库

│ │ ││ userMapper.insert(userPO) │ ││ (不设置任何审计字段) │ ││ ────────────────────────────> │ ││ │ ││ │ 1. 拦截INSERT/UPDATE ││ │ 2. 反射获取所有字段 ││ │ 3. 遍历字段,找空值 ││ │ 4. 按字段名自动填充 ││ │ createUserId → 当前登录ID ││ │ createUserName → 当前用户名 ││ │ createdTime → new Date() ││ │ modifyUserId → 当前登录ID ││ │ modifyUserName → 当前用户名 ││ │ modifiedTime → new Date() ││ │ ────────────────────────────> ││ │ │ 写入完整数据

2.2 核心组件一览

整个方案由4个组件配合:

组件

职责

MyBatisInterceptor

MyBatis拦截器,拦截INSERT/UPDATE,自动填充审计字段

GeneralFieldConstant

审计字段名常量,统一管理6个字段名

SecurityUtil

获取当前登录用户ID和姓名

2.3 PO继承体系:审计字段在哪里定义?

在了解拦截器之前,先看审计字段的定义位置。鹿鲸项目通过PO基类继承体系,让所有实体自动拥有审计字段:

BasePO(创建审计)

├── id (主键,自动生成NanoID)├── createUserId (创建人ID)├── createUserName (创建人姓名)└── createdTime (创建时间)└── SubPO(修改审计)├── modifyUserId (修改人ID)├── modifyUserName (修改人姓名)└── modifiedTime (修改时间)└── MasterPO(业务扩展)├── sortNo (排序号)├── pyCode (拼音码)└── wbCode (五笔码)

BasePO 定义了创建审计字段:

public class BasePO { @Id @Column(value = ”id”, jdbcType = JdbcType.VARCHAR) private String id; public String getId() { if (StringUtils.isEmpty(id)) { this.id = IdUtil.nanoId(); // 主键自动生成 } return this.id; } @Column(value = ”create_user_id”, jdbcType = JdbcType.VARCHAR) private String createUserId; @Column(value = ”create_user_name”, jdbcType = JdbcType.VARCHAR) private String createUserName; @Column(value = ”created_time”, jdbcType = JdbcType.TIMESTAMP) private Date createdTime; }

SubPO 继承BasePO,增加修改审计字段:

public class SubPO extends BasePO { @Column(value = ”modify_user_id”, jdbcType = JdbcType.VARCHAR) private String modifyUserId; @Column(value = ”modify_user_name”, jdbcType = JdbcType.VARCHAR) private String modifyUserName; @Column(value = ”modified_time”, jdbcType = JdbcType.TIMESTAMP) private Date modifiedTime; }

所有业务PO继承MasterPO(继承自SubPO),自动拥有全部6个审计字段,无需重复定义。

2.4 字段名常量:GeneralFieldConstant

GeneralFieldConstant 统一管理6个审计字段的Java属性名:

public class GeneralFieldConstant { // 创建审计 public static final String CREATE_USER_ID = ”createUserId”; public static final String CREATE_USER_NAME = ”createUserName”; public static final String CREATED_TIME = ”createdTime”; // 修改审计 public static final String MODIFY_USER_ID = ”modifyUserId”; public static final String MODIFY_USER_NAME = ”modifyUserName”; public static final String MODIFIED_TIME = ”modifiedTime”; }

拦截器通过反射匹配字段名,找到对应的属性进行填充。

三、拦截器核心源码解析

3.1 拦截器声明

@Component @Intercepts({ @Signature(type = Executor.class, method = ”update”, args = {MappedStatement.class, Object.class}) }) public class MyBatisInterceptor implements Interceptor { }
  • @Intercepts

    声明这是一个MyBatis拦截器

  • @Signature

    拦截Executor.update方法(INSERT、UPDATE、DELETE都走这个方法)

  • args

    方法参数为MappedStatement(SQL信息)和Object(参数对象)

3.2 入口方法:intercept

@Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0]; SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType(); Object parameter = invocation.getArgs()[1]; if (parameter == null) { return invocation.proceed(); // 无参数,直接放行 } if (SqlCommandType.INSERT == sqlCommandType || SqlCommandType.UPDATE == sqlCommandType) { replaceEntityProperty(parameter, sqlCommandType); } return invocation.proceed(); // 执行原始方法 }

执行流程:

拦截到 update 调用├── 参数为null?──→ 是 → 直接放行│ 否│ ↓├── SQL类型判断│ ├── INSERT → dealInsert(填充创建审计)│ ├── UPDATE → dealUpdate(填充修改审计)│ └── DELETE → 直接放行(不处理)└── 执行原始SQL

3.3 支持批量操作:Map参数处理

private void replaceEntityProperty(Object parameter, SqlCommandType sqlCommandType) { if (parameter instanceof Map) { // 批量插入/更新时,MyBatis会将参数包装为Map replaceMap((Map) parameter, sqlCommandType); } else { // 单条操作,直接处理实体对象 replace(parameter, sqlCommandType); } } private void replaceMap(Map parameter, SqlCommandType sqlCommandType) { Collection values = parameter.values(); for (Object value : values) { replace(value, sqlCommandType); // 逐个处理Map中的每个实体 } }

MyBatis在处理批量操作时,会将多个实体对象包装为Map。拦截器自动识别Map类型,遍历每个实体逐一填充。

3.4 INSERT场景:自动填充创建审计

private void dealInsert(Object parameter) { Field[] allFields = getAllFields(parameter); // 获取所有字段(含父类) for (Field field : allFields) { try { // 跳过JDK内部字段 String declaringClassName = field.getDeclaringClass().getName(); if (declaringClassName.startsWith(”java.”) || declaringClassName.startsWith(”javax.”) || declaringClassName.startsWith(”sun.”) || declaringClassName.startsWith(”jdk.”)) { continue; } field.setAccessible(true); Object currentValue = field.get(parameter); // ★ 核心逻辑:已有值的字段不覆盖 if (Objects.nonNull(currentValue)) { field.setAccessible(false); continue; } // 按字段名匹配,自动填充 ProjectId projectIdAnnotation = field.getDeclaredAnnotation(ProjectId.class); if (Objects.nonNull(projectIdAnnotation)) { // @ProjectId注解字段 → 填充当前项目ID field.set(parameter, ”8613d617-f816-44ed-8743-dc53292c1ef9”); } else if (GeneralFieldConstant.CREATE_USER_ID.equals(field.getName())) { // createUserId → 当前登录用户ID field.set(parameter, SecurityUtil.getLoginId()); } else if (GeneralFieldConstant.CREATE_USER_NAME.equals(field.getName())) { // createUserName → 当前登录用户姓名 field.set(parameter, SecurityUtil.getLoginUser().getUserName()); } else if (GeneralFieldConstant.CREATED_TIME.equals(field.getName())) { // createdTime → 当前时间 field.set(parameter, new Date()); } field.setAccessible(false); } catch (Exception e) { log.error(”dealInsert.error:{}”, e.getMessage(), e); } }

    3.5 UPDATE场景:自动填充修改审计

    private void dealUpdate(Object parameter) { Field[] allFields = getAllFields(parameter); for (Field field : allFields) { try { // 跳过JDK内部字段 String declaringClassName = field.getDeclaringClass().getName(); if (declaringClassName.startsWith(”java.”) || ...) { continue; } field.setAccessible(true); Object currentValue = field.get(parameter); // ★ 核心逻辑:已有值的字段不覆盖 if (Objects.nonNull(currentValue)) { field.setAccessible(false); continue; } if (GeneralFieldConstant.MODIFY_USER_ID.equals(field.getName())) { field.set(parameter, SecurityUtil.getLoginId()); } else if (GeneralFieldConstant.MODIFY_USER_NAME.equals(field.getName())) { field.set(parameter, SecurityUtil.getLoginUser().getUserName()); } else if (GeneralFieldConstant.MODIFIED_TIME.equals(field.getName())) { field.set(parameter, new Date()); } field.setAccessible(false); } catch (Exception e) { log.error(”dealInsert.error:{}”, e.getMessage(), e); } } }

    3.6 反射获取所有字段(含父类)

    private Field[] getAllFields(Object object) { Class<?> clazz = object.getClass(); List<Field> fieldList = new ArrayList<>(); while (clazz != null) { // 跳过JDK核心包 String className = clazz.getName(); if (className.startsWith(”java.”) || className.startsWith(”javax.”) || className.startsWith(”sun.”) || className.startsWith(”jdk.”)) { break; } fieldList.addAll(Arrays.asList(clazz.getDeclaredFields())); clazz = clazz.getSuperclass(); // 向上遍历父类 } return fieldList.toArray(new Field[0]); }

    这个方法从当前类开始,逐层向上遍历父类,收集所有字段。对于继承自MasterPO → SubPO → BasePO的实体,能获取到完整的6个审计字段。

    3.7 SecurityUtil:获取当前登录用户

    SecurityUtil 基于Sa-Token获取当前登录用户信息:

    @UtilityClass public class SecurityUtil { private final String USER_KEY = ”DEER_WHALE_LOWCODE”; public String getLoginId() { LoginUser loginUser = getLoginUser(); if (null == loginUser) { return ”Anonymous”; // 未登录时返回匿名 } return loginUser.getUserId(); } public LoginUser getLoginUser() { return (LoginUser) StpUtil.getTokenSession().get(USER_KEY); } }

    四、设计亮点分析

    4.1 "不覆盖"策略

    拦截器最关键的设计是只填充null值的字段

      Object currentValue = field.get(parameter); if (Objects.nonNull(currentValue)) { field.setAccessible(false); continue; // 已有值,跳过 }

      这意味着:

      • INSERT时

        如果开发者手动设置了createUserId,拦截器不会覆盖

      • UPDATE时

        如果开发者想保留原始创建人信息,拦截器不会覆盖

      这个设计既保证了自动填充的便利性,又保留了手动控制的灵活性。

      4.2 异常隔离

      每个字段的填充都包裹在try-catch中:

      try { // 反射操作 } catch (Exception e) { log.error(”dealInsert.error:{}”, e.getMessage(), e); // 不抛出异常,继续处理下一个字段 }

      即使某个字段填充失败(如类型不匹配、安全上下文异常),也不会中断整个SQL执行,保证业务可用性。

      4.3 JDK字段过滤

      反射时主动跳过JDK内部字段:

      • String declaringClassName = field.getDeclaringClass().getName(); if (declaringClassName.startsWith(”java.”) || declaringClassName.startsWith(”javax.”) || declaringClassName.startsWith(”sun.”) || declaringClassName.startsWith(”jdk.”)) { continue; }

      这在Java 9+模块系统下尤为重要,直接反射访问JDK内部字段会触发InaccessibleObjectException

      五、传统方案 vs 拦截器方案:对比

      5.1 代码量对比

      以50个实体为例:

      维度

      传统方案

      拦截器方案

      每个Service的insert方法

      3行审计代码

      0行

      每个Service的update方法

      3行审计代码

      0行

      50个实体的总审计代码

      50 × 6 = 300行

      0行

      拦截器本身

      0行

      1个文件(~230行)

      合计300行散落代码230行集中代码

      拦截器代码集中在一处,可维护性远超300行散落在50个文件中的代码。

      5.2 能力对比

      能力

      传统方案

      拦截器方案

      自动填充创建人

      ❌ 手动set

      ✅ 自动

      自动填充修改人

      ❌ 手动set

      ✅ 自动

      自动填充创建时间

      ❌ 手动set

      ✅ 自动

      自动填充修改时间

      ❌ 手动set

      ✅ 自动

      批量操作支持

      ❌ 每条都要set

      ✅ 自动处理Map参数

      不覆盖已有值

      ❌ 需手动判断

      ✅ 自动判断null

      遗漏风险

      🔴 高

      🟢 无

      5.3 遗漏场景对比

      传统方案的灾难场景

      拦截器方案:上述代码完全不需要修改,拦截器自动填充所有审计字段,不可能遗漏

      六、实际运行效果

      6.1 开发者代码

      • // 某开发者新增了一个Service方法,忘了写审计字段 public boolean importUsers(List<AddUserDTO> dtos) { for (AddUserDTO dto : dtos) { UserPO userPO = new UserPO(); userPO.setUserName(dto.getUserName()); // ❌ 忘了 setCreateUserId、setCreatedTime... userMapper.insert(userPO); } } // 结果:新增条数据全部没有创建人信息,无法追溯 @Service public class UserServiceImpl extends OneEntityServiceImpl<...> { @Override public boolean insertInfo(AddUserDTO dto) { UserPO userPO = userConverter.toPO(dto); // 不设置任何审计字段,直接保存 return this.save(userPO); } }

      6.2 数据库中的数据

      • SELECT id, user_name, create_user_id, create_user_name, created_time, modify_user_id, modify_user_name, modified_time FROM dw_system.”user” WHERE id = 'abc123';

      结果:

      id

      user_name

      create_user_id

      create_user_name

      created_time

      modify_user_id

      modify_user_name

      modified_time

      abc123

      张三

      u001

      管理员

      2026-06-17 10:30:00

      NULL

      NULL

      NULL

      所有创建审计字段已被自动填充,修改审计字段为NULL(因为是新增)。

      6.3 更新后的数据

      -- 执行更新操作后 SELECT id, modify_user_id, modify_user_name, modified_time FROM dw_system.”user” WHERE id = 'abc123';

      id

      modify_user_id

      modify_user_name

      modified_time

      abc123

      u002

      李四

      2026-06-17 14:20:00

      修改审计字段已被自动填充,创建审计字段保持不变(因为已有值,不覆盖)。


      七、与MetaObjectHandler对比

      MyBatis-Plus提供了MetaObjectHandler接口实现类似功能,对比一下两种方案:

      7.1 MyBatis-Plus的MetaObjectHandler

      public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, ”createUserId”, String.class, SecurityUtil.getLoginId()); this.strictInsertFill(metaObject, ”createdTime”, Date.class, new Date()); } @Override public void updateFill(MetaObject metaObject) { his.strictUpdateFill(metaObject, ”modifyUserId”, String.class, SecurityUtil.getLoginId()); this.strictUpdateFill(metaObject, ”modifiedTime”, Date.class, new Date()); } }

      7.2 对比

      维度

      MetaObjectHandler

      MyBatisInterceptor

      框架依赖

      依赖MyBatis-Plus

      纯MyBatis拦截器,框架无关

      字段标记

      需要在实体字段上加@TableField(fill = FieldFill.INSERT)

      按字段名匹配,无需额外注解

      批量支持

      需要额外配置

      天然支持(自动处理Map参数)

      控制粒度

      精确到字段

      精确到字段

      不覆盖策略

      strictFill方法已内置

      手动判断null

      鹿鲸项目使用的是MyBatis-Flex而非MyBatis-Plus,因此选择了基于原生MyBatis拦截器接口实现,设计思路与MetaObjectHandler殊途同归,但更具框架无关性。

      八、使用指南

      8.1 自动生效,零配置

      只要PO继承了BasePOSubPO,审计字段就会自动填充,无需任何额外配置:​​​​​​​

      // PO定义 @Table(schema = ”dw_system”, value = ”user”) public class UserPO extends MasterPO { // MasterPO → SubPO → BasePO private String userName; private String loginCode; // 不需要定义审计字段,基类已经有了 } // Service代码 public boolean insertInfo(AddUserDTO dto) { UserPO userPO = userConverter.toPO(dto); // 不需要设置审计字段,拦截器自动填充 return this.save(userPO); } // 完事。审计字段已经自动填充好了。

      8.2 手动覆盖

      如果某些场景需要手动指定创建人(如数据迁移),直接设置即可,拦截器不会覆盖:

        public boolean importData(ImportDTO dto) { UserPO userPO = new UserPO(); userPO.setUserName(dto.getUserName()); userPO.setCreateUserId(”system”); // 手动指定 userPO.setCreateUserName(”数据迁移脚本”); // 手动指定 userPO.setCreatedTime(dto.getOriginTime()); // 使用原始时间 // 拦截器检测到这些字段已有值,不会覆盖 return this.save(userPO); }

        九、局限与改进方向

        9.1 当前局限

        局限

        说明

        反射性能

        每次INSERT/UPDATE都通过反射遍历字段,有轻微性能开销

        未登录场景

        SecurityUtil.getLoginId()

        在未登录时返回 "Anonymous",可能不符合某些业务需求

        字段名耦合

        拦截器按固定字段名匹配,字段名变更需要同步修改常量

        9.2 改进方向

        1. 项目ID动态化

          将硬编码改为从SecurityUtilRequestContext动态获取当前项目ID

        2. 缓存反射结果

          对Class的Field数组做缓存,避免每次操作都反射获取

        3. 注解驱动扩展

          将字段名匹配改为注解匹配,定义@AuditField(type = AuditType.CREATE_USER_ID)等注解

        4. 异步线程支持

          当前SecurityUtil依赖ThreadLocal,异步线程中可能获取不到用户信息

        十、总结

        核心价值

        维度

        价值

        开发效率

        开发者无需写一行审计代码,专注业务逻辑

        数据完整性

        不可能遗漏审计字段,数据追溯链完整

        代码整洁

        审计逻辑从业务代码中彻底剥离

        统一管理

        所有审计逻辑集中在一个拦截器中,修改一处生效全局

        灵活可控

        "不覆盖"策略保留了手动控制的能力

        核心设计思想

        这套方案的本质是AOP思想在持久层的落地

        • 横切关注点

          审计字段填充是所有实体共有的需求,属于横切关注点

        • 统一拦截

          通过MyBatis拦截器在SQL执行前统一处理

        • 声明式

          开发者只需让PO继承基类,审计能力自动获得

        这与Spring的@Transactional(事务管理)、@Cacheable(缓存)的设计思想一致——将通用逻辑提取到框架层,让业务代码只关注业务

        最后的一个比喻:

        没有拦截器的日子,就像每个员工入职都要自己填考勤卡——忘了填就没有记录。
        有了拦截器,就像装了门禁系统——刷卡进门的那一刻,时间、人员信息已自动记录,谁也忘不了。

        关注引导

        希望这篇文章对你有所帮助!如果觉得有用,欢迎点赞、收藏、分享~

        「AI低码加速派」

        专注于项目架构、低代码平台建设的实战分享。

        在这里你会看到:

        • 大型项目架构设计的真实案例拆解

        • 框架级抽象设计的思路与方法论

        • AOP、注解驱动、泛型模板等进阶技巧的落地实践

        • 从 0 到 1 构建企业级项目的完整复盘

        扫码关注,一起成长!

        关注+点赞 + 转发,是我持续输出的最大动力~