基于 MyBatis-Plus 拦截器实现“结账后禁止修改”的优雅方案
技术博客:基于 MyBatis-Plus 拦截器实现“结账后禁止修改”的优雅方案讨论(一)
作者: 阿波
场景: 财务系统、ERP、进销存等涉及“会计期间结账”的业务
核心方案:状态字段 + 拦截器 = 无侵入式数据保护
一、业务场景:为什么需要“结账后禁止修改”?
在财务类系统中,常见的一个需求是:
每月末执行“结账”操作,结账后当月的所有业务数据(如发票、付款、凭证)不能再被修改。
🔹 传统做法的问题
-
在每个 Service 层写判断逻辑
if (invoice.getCloseStatus().equals("Y")) {throw new RuntimeException("已结账,不能修改"); }
- ❌ 重复代码多
- ❌ 容易遗漏
- ❌ 业务代码被污染
-
用 AOP 切 Service 方法
- ❌ 侵入性强
- ❌ 难维护
- ❌ 无法精准控制到具体数据
二、目标:我们想要什么?
需求 | 说明 |
---|---|
✅ 只拦截特定几张表 | 如 ap_invoice , gl_voucher 等 6 张财务表 |
✅ 只拦截特定数据 | closeStatus = 'Y' 的数据才拦截 |
✅ 未结账数据可改 | closeStatus ≠ 'Y' 的数据正常更新 |
✅ 不改业务代码 | Service 层调 updateById 不加任何判断 |
✅ 统一控制、易于维护 | 一处配置,全局生效 |
三、理论方法:为什么用 MyBatis-Plus 拦截器?
MyBatis-Plus 提供了强大的 InnerInterceptor
机制,可以在 SQL 执行前进行拦截。
✅ 核心优势
- 底层拦截:在 SQL 执行前介入,业务无感知
- 精准控制:可获取
MappedStatement
、参数对象、SQL 类型 - 无侵入:不需要在 Controller/Service 写判断
- 高性能:只对目标表做判断,其他操作完全放行
🎯 我们的策略
“状态字段 + 拦截器”双剑合璧
- 数据库加
is_close
字段(或close_status
),标记是否已结账 - 拦截器自动读取该字段,
'Y'
→ 拦截,'N'
→ 放行
四、完整实现步骤
✅ 第一步:数据库加字段(所有目标表)
为需要“结账保护”的 6 张表统一添加字段:
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'
:已结账(禁止修改)
✅ 第二步:实体类加字段
public class ApInvoice {private Long id;private String invoiceNo;private BigDecimal amount;// ... 其他字段private String closeStatus; // 注意:要和数据库字段对应// getter/setterpublic String getCloseStatus() {return closeStatus;}public void setCloseStatus(String closeStatus) {this.closeStatus = closeStatus;}
}
⚠️ 其他 5 个实体类同样操作。
✅ 第三步:编写拦截器(核心代码)
// 文件:AccountingInterceptor.java
package com.yourproject.interceptor;import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.springframework.stereotype.Component;import java.lang.reflect.Field;
import java.util.Set;/*** 结账拦截器:自动阻止对已结账数据的修改*/
@Component
public class AccountingInterceptor implements InnerInterceptor {// ✅ 配置你要拦截的 Mapper 类名(对应6张表)private static final Set<String> TARGET_MAPPERS = Set.of("ApInvoiceMapper","ApPaymentMapper","GlVoucherMapper","ArInvoiceMapper","PoOrderMapper","InvDeliveryMapper");@Overridepublic void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) {// 1. 只处理 UPDATE 操作if (ms.getSqlCommandType() != SqlCommandType.UPDATE) {return;}// 2. 判断是否是目标表String msId = ms.getId();if (TARGET_MAPPERS.stream().noneMatch(msId::contains)) {return; // 不是目标表,放行}// 3. 获取要更新的实体对象Object entity = getEntityFromParameter(parameter);if (entity == null) return;// 4. 反射获取 closeStatus 字段String closeStatus = getFieldValue(entity, "closeStatus", String.class);// 5. 如果 closeStatus == 'Y',说明已结账 → 拦截更新if ("Y".equals(closeStatus)) {throw new RuntimeException("❌ 数据已结账(closeStatus = Y),禁止修改!");}}private Object getEntityFromParameter(Object parameter) {if (parameter instanceof java.util.Map) {return ((java.util.Map<?, ?>) parameter).get("et"); // MyBatis-Plus 默认 key}return parameter;}private <T> T getFieldValue(Object entity, String fieldName, Class<T> type) {try {Field field = entity.getClass().getDeclaredField(fieldName);field.setAccessible(true);Object value = field.get(entity);return type.isInstance(value) ? type.cast(value) : null;} catch (Exception e) {return null; // 字段不存在或访问失败}}
}
✅ 第四步:注册拦截器
// 文件:MyBatisConfig.java
package com.yourproject.config;import com.yourproject.interceptor.AccountingInterceptor;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;@Configuration
public class MyBatisConfig {@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new AccountingInterceptor());return interceptor;}
}
五、效果演示
操作 | 表 | closeStatus | 是否允许更新 |
---|---|---|---|
更新 ap_invoice | ap_invoice | Y | ❌ 抛异常 |
更新 ap_invoice | ap_invoice | N | ✅ 成功 |
更新 user 表 | user | Y | ✅ 成功(不在拦截列表) |
新增 ap_invoice | ap_invoice | Y | ✅ 成功(不是 update) |
🎉 业务代码无需任何改变:
apInvoiceService.updateById(invoice); // 直接调,拦截器自动判断
六、方案优势总结
优势 | 说明 |
---|---|
✅ 彻底无侵入 | 业务代码零修改 |
✅ 统一控制 | 一个拦截器管 6 张表 |
✅ 精准拦截 | 只拦 closeStatus='Y' 的数据 |
✅ 易于扩展 | 新增表?加个 Mapper 名就行 |
✅ 可配置化 | TARGET_MAPPERS 可改为从配置中心读取 |
七、注意事项
- 字段命名统一:建议所有表用
close_status
或is_close
,保持一致 - 结账逻辑:结账时批量更新这 6 张表的
close_status = 'Y'
- 性能影响:拦截器只做简单判断,几乎无性能损耗
- 反射安全:生产环境建议加缓存(如缓存字段反射对象),但通常不需要
八、结语
“状态字段 + 拦截器”是一个简单、稳定、可维护的解决方案。
它把业务规则(已结账不能改)和技术实现(SQL 拦截)完美分离,让业务代码专注业务,让基础设施默默守护数据安全。
下次当你遇到“某种状态下禁止修改”的需求时,不妨试试这个模式:
加个状态字段,写个拦截器,业务代码一行不改,搞定!
📌 适用框架:Spring Boot + MyBatis-Plus 3.4+
✅ 一句话总结:
只要把某条数据的
close_status
改成'Y'
,它就被“锁住”了,任何人、任何操作都无法修改它。
只要把close_status
改回'N'
,它就“解锁”了,可以正常修改。
🎯 举个实际例子:
数据库中有一条发票:
UPDATE ap_invoice
SET close_status = 'Y'
WHERE id = 1001;
👉 结果:这条发票(id=1001)立刻被锁定,任何人调 updateById(1001)
都会失败,报错:“数据已结账,禁止修改”。
这样有个问题,后来发现要修改,财务主管解锁:
UPDATE ap_invoice
SET close_status = 'N'
WHERE id = 1001;
👉 结果:这条发票被锁死,无法解锁,现在不可以正常编辑、保存。
✅ 你不需要做任何其他事情:
你要做的 | 系统自动做的 |
---|---|
执行 SQL 把 close_status = 'Y' | 拦截器在每次 update 前检查该字段 |
执行 SQL 把 close_status = 'N' | 拦截器发现不是 'Y' ,放行更新 |
调 service.updateById(invoice) | 拦截器判断后:能改就改,不能改就抛异常 |
💡 这就是“状态驱动 + 拦截器”的威力:
- 锁住数据? →
UPDATE xxx SET close_status = 'Y' WHERE id = ?
- 无法解锁数据? →
UPDATE xxx SET close_status = 'N' WHERE id = ?
- 业务代码? → 一行不用改
- 安全性? → 底层拦截,绕不过去
🚀 延伸用法(你以后也可能用到):
场景 | 实现方式 |
---|---|
整个组织结账 | UPDATE ap_invoice SET close_status = 'Y' WHERE org_id = 1001 AND period = '2025-08' |
批量解锁 | UPDATE ap_invoice SET close_status = 'N' WHERE id IN (1001,1002,1003) |
查看哪些数据被锁了 | SELECT * FROM ap_invoice WHERE close_status = 'Y' |
✅ 总结:你只需要记住
🔒 锁住数据 =
close_status = 'Y'
🗝️ 解锁数据 =close_status = 'N'
🤖 拦截器自动拦,业务代码不用管
- 按照当前的拦截器逻辑,当执行一条将 close_status 设置为 ‘Y’ 的 UPDATE 语句时,拦截器会检测到 close_status 的值为 ‘Y’,从而认为这条数据已经被结账,进而抛出异常阻止更新 —— 也就是说,你连“设置结账状态”这个操作本身也做不了!
- 如果执行 UPDATE 操作时,传入的数据(实体对象)中没有设置 close_status 字段(即没有调用 setCloseStatus),但表中是有这个字段的,那么这种情况下,拦截器会不会拦截这次更新
但是这个仍有借鉴意义,略微修改拦截器就行,比如
✅ 目标
我们要实现:
- 日常的
updateById
操作,被拦截器保护(close_status='Y'
时不能改) - 但管理员可以调用
/unlock
接口,把'Y'
改成'N'
(解锁)
✅ 方案一:专用 SQL + 专用接口(推荐 ★★★★★)
下一篇专门讨论
✅ 方案二:在拦截器中加“放行标记”(备选)
1. 前端传一个特殊标记(如 forceUpdate = true
)
// 前端传
{"id": 1001,"closeStatus": "N","forceUpdate": true // 特殊标记
}
2. 拦截器中判断
@Override
public void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) {// ... 判断表、获取实体// 如果带有 forceUpdate = true,且是管理员,就放行if (parameter instanceof Map) {Boolean force = (Boolean) ((Map<?, ?>) parameter).get("forceUpdate");if (Boolean.TRUE.equals(force) && hasAdminRole()) {return; // 放行,不拦截}}// 正常拦截逻辑if ("Y".equals(oldCloseStatus) && !"N".equals(newCloseStatus)) {throw new RuntimeException("禁止修改");}
}
⚠️ 缺点:不够安全,前端可能伪造
forceUpdate
✅ 方案三:用 Service 层事务 + 临时关闭拦截器(不推荐)
- 在
unlock()
方法上加@Transactional
- 先查,再手动改
close_status
,再updateById
- 但依然会被拦截器拦住,除非你改拦截器逻辑
❌ 太绕,不推荐。
✅ 最终推荐:方案一(专用接口 + 专用 SQL)
graph TDA[管理员点击“解锁”] --> B[调用 /api/invoice/1001/unlock]B --> C[后端调用 unlock() 方法]C --> D[执行专用 SQL: UPDATE close_status = 'N']D --> E[成功解锁]
- 普通更新:走
updateById
→ 被拦截器保护 - 解锁操作:走
/unlock
→ 走专用 SQL → 绕过拦截器
✅ 总结
操作 | 走的路径 | 是否被拦截 |
---|---|---|
修改发票金额 | updateById | ✅ 被拦截(如果 close_status='Y' ) |
调用 /unlock | 专用 SQL | ❌ 不走拦截器,成功解锁 |
✅ 你只需要记住:
🔑 “通用更新”用于日常业务,受保护
🚪 “专用解锁”用于管理操作,走后门
这才是既安全又灵活的生产级设计。
点赞 + 收藏,下次结账功能直接抄作业! 💡