基于 MyBatis-Plus 拦截器实现“结账后禁止修改”的优雅方案

技术博客:基于 MyBatis-Plus 拦截器实现“结账后禁止修改”的优雅方案讨论(一)

作者: 阿波
场景: 财务系统、ERP、进销存等涉及“会计期间结账”的业务
核心方案:状态字段 + 拦截器 = 无侵入式数据保护


一、业务场景:为什么需要“结账后禁止修改”?

在财务类系统中,常见的一个需求是:

每月末执行“结账”操作,结账后当月的所有业务数据(如发票、付款、凭证)不能再被修改。

🔹 传统做法的问题

  1. 在每个 Service 层写判断逻辑

    if (invoice.getCloseStatus().equals("Y")) {
        throw new RuntimeException("已结账,不能修改");
    }
    
    • ❌ 重复代码多
    • ❌ 容易遗漏
    • ❌ 业务代码被污染
  2. 用 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/setter
    public 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"
    );

    @Override
    public 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 {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new AccountingInterceptor());
        return interceptor;
    }
}

五、效果演示

操作closeStatus是否允许更新
更新 ap_invoiceap_invoiceY❌ 抛异常
更新 ap_invoiceap_invoiceN✅ 成功
更新 user 表userY✅ 成功(不在拦截列表)
新增 ap_invoiceap_invoiceY✅ 成功(不是 update)

🎉 业务代码无需任何改变:

apInvoiceService.updateById(invoice); // 直接调,拦截器自动判断

六、方案优势总结

优势说明
彻底无侵入业务代码零修改
统一控制一个拦截器管 6 张表
精准拦截只拦 closeStatus='Y' 的数据
易于扩展新增表?加个 Mapper 名就行
可配置化TARGET_MAPPERS 可改为从配置中心读取

七、注意事项

  1. 字段命名统一:建议所有表用 close_statusis_close,保持一致
  2. 结账逻辑:结账时批量更新这 6 张表的 close_status = 'Y'
  3. 性能影响:拦截器只做简单判断,几乎无性能损耗
  4. 反射安全:生产环境建议加缓存(如缓存字段反射对象),但通常不需要

八、结语

“状态字段 + 拦截器”是一个简单、稳定、可维护的解决方案。

它把业务规则(已结账不能改)和技术实现(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'
🤖 拦截器自动拦,业务代码不用管

  1. 按照当前的拦截器逻辑,当执行一条将 close_status 设置为 ‘Y’ 的 UPDATE 语句时,拦截器会检测到 close_status 的值为 ‘Y’,从而认为这条数据已经被结账,进而抛出异常阻止更新 —— 也就是说,你连“设置结账状态”这个操作本身也做不了!​​
  2. 如果执行 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 TD
    A[管理员点击“解锁”] --> 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❌ 不走拦截器,成功解锁

你只需要记住:

🔑 “通用更新”用于日常业务,受保护
🚪 “专用解锁”用于管理操作,走后门

这才是既安全又灵活的生产级设计。

点赞 + 收藏,下次结账功能直接抄作业! 💡

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值