基于 MyBatis-Plus 拦截器实现锁定特殊数据(二)
技术博客:基于 MyBatis-Plus 拦截器实现“结账后禁止修改”的优雅方案(终极版)专用 SQL + 专用接口方案
作者: 阿波
场景: 财务系统、ERP、进销存等涉及“会计期间结账”的业务
核心方案:状态字段 + 拦截器 + 专用解锁接口 + 通用化设计 = 无侵入式数据保护
一、业务场景:为什么需要“结账后禁止修改”?
在财务类系统中,常见的一个需求是:
每月末执行“结账”操作,结账后当月的所有业务数据(如发票、付款、凭证)不能再被修改。
🔹 传统做法的问题
-
在每个 Service 层写判断逻辑
if (invoice.getCloseStatus().equals("Y")) {throw new RuntimeException("已结账,不能修改"); }
- ❌ 重复代码多
- ❌ 容易遗漏
- ❌ 业务代码被污染
-
用 AOP 切 Service 方法
- ❌ 侵入性强
- ❌ 难维护
- ❌ 无法精准控制到具体数据
二、目标:我们想要什么?
需求 | 说明 |
---|---|
✅ 支持多张表 | 如 ap_invoice , ap_payment , gl_voucher 等 6 张财务表 |
✅ 只拦截特定数据 | closeStatus = 'Y' 的数据才拦截 |
✅ 未结账数据可改 | closeStatus ≠ 'Y' 的数据正常更新 |
✅ 不改业务代码 | Service 层调 updateById 不加任何判断 |
✅ 可解锁 | 管理员能将 'Y' 改回 'N' (关键!) |
✅ 统一控制、易于维护 | 一处配置,全局生效 |
三、理论方法:为什么用 MyBatis-Plus 拦截器?
MyBatis-Plus 提供了强大的 InnerInterceptor
机制,可以在 SQL 执行前进行拦截。
✅ 核心优势
- 底层拦截:在 SQL 执行前介入,业务无感知
- 精准控制:可获取
MappedStatement
、参数对象、SQL 类型 - 无侵入:不需要在 Controller/Service 写判断
- 高性能:只对目标表做判断,其他操作完全放行
🎯 我们的策略
“状态字段 + 拦截器 + 专用解锁 + 通用化设计”四剑合璧
- 数据库加
close_status
字段,标记是否已结账 - 拦截器自动读取该字段,
'Y'
→ 拦截,'N'
→ 放行 - 但必须允许管理员通过专用接口解锁
- 支持多表通用处理
⚠️ 关键认知:所有
UPDATE
操作都会触发beforeUpdate
,无法“绕开”拦截器,只能“识别并放行”特定操作。
四、完整实现步骤(专用 SQL + 专用接口)
✅ 第一步:数据库加字段(所有目标表)
ALTER TABLE ap_invoice ADD COLUMN close_status VARCHAR(1) DEFAULT 'N';
ALTER TABLE ap_payment ADD COLUMN close_status VARCHAR(1) DEFAULT 'N';
ALTER TABLE gl_voucher ADD COLUMN close_status VARCHAR(1) DEFAULT 'N';
-- ... 其他3张表
💡 建议值:
'N'
:未结账(可修改)'Y'
:已结账(禁止修改)
✅ 第二步:定义统一接口
// LockableEntity.java
public interface LockableEntity {Long getId();String getCloseStatus();void setCloseStatus(String closeStatus);
}
✅ 第三步:实体类实现接口
public class ApInvoice implements LockableEntity {private Long id;private String invoiceNo;private String closeStatus;// getter/setterpublic Long getId() { return id; }public void setId(Long id) { this.id = id; }public String getCloseStatus() { return closeStatus; }public void setCloseStatus(String closeStatus) { this.closeStatus = closeStatus; }
}// 其他5个实体类同样实现 LockableEntity 接口
✅ 第四步:编写专用解锁 SQL(每张表一个)
// ApInvoiceMapper.java
@Mapper
public interface ApInvoiceMapper extends BaseMapper<ApInvoice> {@Update("UPDATE ap_invoice SET close_status = #{closeStatus} WHERE id = #{id}")int updateCloseStatus(@Param("id") Long id, @Param("closeStatus") String closeStatus);
}// ApPaymentMapper.java
@Mapper
public interface ApPaymentMapper extends BaseMapper<ApPayment> {@Update("UPDATE ap_payment SET close_status = #{closeStatus} WHERE id = #{id}")int updateCloseStatus(@Param("id") Long id, @Param("closeStatus") String closeStatus);
}// ... 其他4个 Mapper 同样添加
✅ 第五步:编写通用拦截器(核心代码)
// AccountingInterceptor.java
@Component
public class AccountingInterceptor implements InnerInterceptor {// ✅ 配置你要拦截的 Mapper 类名private static final Set<String> TARGET_MAPPERS = Set.of("ApInvoiceMapper", "ApPaymentMapper", "GlVoucherMapper","ArInvoiceMapper", "PoOrderMapper", "InvDeliveryMapper");// ✅ 专用解锁方法的后缀private static final String UNLOCK_METHOD_SUFFIX = "updateCloseStatus";// ✅ 存储所有目标 Mapperprivate final Map<String, BaseMapper<? extends LockableEntity>> mapperMap = new HashMap<>();// 通过构造函数注入所有目标 Mapperpublic AccountingInterceptor(ApInvoiceMapper apInvoiceMapper,ApPaymentMapper apPaymentMapper,GlVoucherMapper glVoucherMapper,ArInvoiceMapper arInvoiceMapper,PoOrderMapper poOrderMapper,InvDeliveryMapper invDeliveryMapper) {mapperMap.put("ApInvoiceMapper", apInvoiceMapper);mapperMap.put("ApPaymentMapper", apPaymentMapper);mapperMap.put("GlVoucherMapper", glVoucherMapper);mapperMap.put("ArInvoiceMapper", arInvoiceMapper);mapperMap.put("PoOrderMapper", poOrderMapper);mapperMap.put("InvDeliveryMapper", invDeliveryMapper);}@Overridepublic void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) {// 1. 只处理 UPDATE 操作if (ms.getSqlCommandType() != SqlCommandType.UPDATE) {return;}String msId = ms.getId();// 2. ✅ 如果是专用解锁方法,直接放行if (msId.endsWith(UNLOCK_METHOD_SUFFIX)) {return;}// 3. 判断是否是目标表String mapperName = extractMapperName(msId);if (mapperName == null || !TARGET_MAPPERS.contains(mapperName)) {return;}// 4. 获取要更新的实体LockableEntity entity = getEntityFromParameter(parameter);if (entity == null || entity.getId() == null) {return;}// 5. 获取对应的 Mapper 并查询旧数据@SuppressWarnings("unchecked")BaseMapper<LockableEntity> mapper = (BaseMapper<LockableEntity>) mapperMap.get(mapperName);if (mapper == null) return;LockableEntity oldEntity = mapper.selectById(entity.getId());if (oldEntity == null) return;// 6. 核心逻辑:如果旧状态是 'Y',则禁止任何修改if ("Y".equals(oldEntity.getCloseStatus())) {throw new RuntimeException("❌ 数据已结账(closeStatus = Y),禁止修改!");}}// 从 MappedStatement ID 中提取 Mapper 类名private String extractMapperName(String msId) {int lastDot = msId.lastIndexOf(".");if (lastDot > 0) {String className = msId.substring(lastDot + 1);return TARGET_MAPPERS.stream().filter(name -> className.startsWith(name)).findFirst().orElse(null);}return null;}// 从参数中获取实体对象private LockableEntity getEntityFromParameter(Object parameter) {if (parameter instanceof LockableEntity) {return (LockableEntity) parameter;}if (parameter instanceof Map) {Object et = ((Map<?, ?>) parameter).get("et");if (et instanceof LockableEntity) {return (LockableEntity) et;}}return null;}
}
✅ 第六步:注册拦截器
@Configuration
public class MyBatisConfig {@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new AccountingInterceptor(// 注入所有目标 MapperapInvoiceMapper, apPaymentMapper, glVoucherMapper,arInvoiceMapper, poOrderMapper, invDeliveryMapper));return interceptor;}
}
五、效果演示
操作 | 方法 | 旧 closeStatus | 是否允许 |
---|---|---|---|
修改发票金额 | updateById | Y | ❌ 拦截 |
修改付款金额 | updateById | N | ✅ 允许 |
调用 updateCloseStatus(id, "N") | 专用方法 | Y | ✅ 放行(方法名匹配) |
新增发票 | insert | - | ✅ 允许 |
六、方案优势总结
优势 | 说明 |
---|---|
✅ 彻底无侵入 | 日常业务代码零修改 |
✅ 安全解锁 | 只能通过专用接口解锁,防止误操作 |
✅ 权限控制 | 解锁操作可加入角色校验 |
✅ 统一管理 | 一个拦截器管 6 张表 |
✅ 逻辑清晰 | 拦截 vs 解锁 路径分离 |
✅ 可扩展 | 新增表只需实现接口 + 注入 Mapper |
七、注意事项(终极版)
-
必须查旧数据
为了准确判断“修改前是否已锁定”,必须查询数据库旧值。 -
专用方法名是关键
updateCloseStatus
这样的方法名要足够特殊,避免与其他方法冲突。 -
权限必须加在 Service 层
即使拦截器放行了updateCloseStatus
,也要在 Service 中做权限校验。 -
性能考虑
拦截器中查旧数据会多一次 DB 查询,建议:- 为
id
字段加索引 - 高频表可加缓存(如 Redis)
- 为
-
Mapper 注入
拦截器通过构造函数注入所有目标 Mapper,确保能调用selectById
。
八、结语
“状态字段 + 拦截器 + 专用解锁 + 通用化”是一个简单、安全、可维护的解决方案。
它解决了:
- ✅ 日常数据保护:
close_status='Y'
→ 禁止修改 - ✅ 紧急解锁需求:通过专用接口安全解锁
- ✅ 业务无侵入:日常操作完全透明
- ✅ 多表统一管理:一套代码管所有财务表
下次当你遇到“某种状态下禁止修改”的需求时,记住这个模式:
加状态字段,写拦截器,配专用解锁,实现通用接口!
📌 适用框架:Spring Boot + MyBatis-Plus 3.4+
点赞 + 收藏,下次结账功能直接抄作业! 💡