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

发布时间:2026/7/29 9:41:46
架构实战第4篇:谁动了我的数据——MyBatis拦截器实现审计字段自动注入 摘要:任何企业级系统都需要记录谁创建了这条数据、谁最后修改了它。如果让每个开发者在每次save/update时手动set这些字段既繁琐又容易遗漏。本文结合《鹿鲸项目管理工具》的MyBatisInterceptor拦截器展示如何用一个MyBatis插件实现审计字段的全自动注入——开发者无需写一行审计代码系统自动填充创建人、修改人、创建时间、修改时间。在前面的几篇文章中我们分别讲解了架构实战第1篇构建统一全局响应封装打造标准化接口契约架构实战第2篇:项目中各种O(PO\BO\DTO\VO)的实践与思考架构实战第3篇:三个文件消灭90%重复代码-泛型CRUD架构解析今天我们主要讲解MyBatis拦截器实现审计字段自动注入一、问题审计字段手动填充有多痛1.1 审计字段是刚需几乎所有业务表都有这几个字段字段说明示例create_user_id创建人IDa1b2c3...create_user_name创建人姓名张三created_time创建时间2026-06-17 10:30:00modify_user_id修改人IDd4e5f6...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个组件配合组件职责MyBatisInterceptorMyBatis拦截器拦截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 字段名常量GeneralFieldConstantGeneralFieldConstant 统一管理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方法参数为MappedStatementSQL信息和Object参数对象3.2 入口方法interceptOverride 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 → 直接放行不处理│└── 执行原始SQL3.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(); ListField 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(ListAddUserDTO 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;结果iduser_namecreate_user_idcreate_user_namecreated_timemodify_user_idmodify_user_namemodified_timeabc123张三u001管理员2026-06-17 10:30:00NULLNULLNULL所有创建审计字段已被自动填充修改审计字段为NULL因为是新增。6.3 更新后的数据-- 执行更新操作后 SELECT id, modify_user_id, modify_user_name, modified_time FROM dw_system.”user” WHERE id abc123;idmodify_user_idmodify_user_namemodified_timeabc123u002李四2026-06-17 14:20:00修改审计字段已被自动填充创建审计字段保持不变因为已有值不覆盖。七、与MetaObjectHandler对比MyBatis-Plus提供了MetaObjectHandler接口实现类似功能对比一下两种方案7.1 MyBatis-Plus的MetaObjectHandlerpublic 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 对比维度MetaObjectHandlerMyBatisInterceptor框架依赖依赖MyBatis-Plus纯MyBatis拦截器框架无关字段标记需要在实体字段上加TableField(fill FieldFill.INSERT)按字段名匹配无需额外注解批量支持需要额外配置天然支持自动处理Map参数控制粒度精确到字段精确到字段不覆盖策略strictFill方法已内置手动判断null鹿鲸项目使用的是MyBatis-Flex而非MyBatis-Plus因此选择了基于原生MyBatis拦截器接口实现设计思路与MetaObjectHandler殊途同归但更具框架无关性。八、使用指南8.1 自动生效零配置只要PO继承了BasePO或SubPO审计字段就会自动填充无需任何额外配置​​​​​​​// 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 改进方向项目ID动态化将硬编码改为从SecurityUtil或RequestContext动态获取当前项目ID缓存反射结果对Class的Field数组做缓存避免每次操作都反射获取注解驱动扩展将字段名匹配改为注解匹配定义AuditField(type AuditType.CREATE_USER_ID)等注解异步线程支持当前SecurityUtil依赖ThreadLocal异步线程中可能获取不到用户信息十、总结核心价值维度价值开发效率开发者无需写一行审计代码专注业务逻辑数据完整性不可能遗漏审计字段数据追溯链完整代码整洁审计逻辑从业务代码中彻底剥离统一管理所有审计逻辑集中在一个拦截器中修改一处生效全局灵活可控不覆盖策略保留了手动控制的能力核心设计思想这套方案的本质是AOP思想在持久层的落地横切关注点审计字段填充是所有实体共有的需求属于横切关注点统一拦截通过MyBatis拦截器在SQL执行前统一处理声明式开发者只需让PO继承基类审计能力自动获得这与Spring的Transactional事务管理、Cacheable缓存的设计思想一致——将通用逻辑提取到框架层让业务代码只关注业务。最后的一个比喻没有拦截器的日子就像每个员工入职都要自己填考勤卡——忘了填就没有记录。有了拦截器就像装了门禁系统——刷卡进门的那一刻时间、人员信息已自动记录谁也忘不了。关注引导希望这篇文章对你有所帮助如果觉得有用欢迎点赞、收藏、分享~「AI低码加速派」专注于项目架构、低代码平台建设的实战分享。在这里你会看到大型项目架构设计的真实案例拆解框架级抽象设计的思路与方法论AOP、注解驱动、泛型模板等进阶技巧的落地实践从 0 到 1 构建企业级项目的完整复盘扫码关注一起成长关注点赞 转发是我持续输出的最大动力~