个人项目踩坑实录:被“逻辑删除”和“唯一索引”反复拷打后,我找到了这个神仙解法

个人项目踩坑实录:被“逻辑删除”和“唯一索引”反复拷打后,我找到了这个神仙解法

_

最近在做自己的 OA 系统项目,写到 RBAC(基于角色的访问控制)权限模块时。本来以为给角色写个增删改查(CRUD)就是分分钟的事,结果在“唯一索引”和“逻辑删除”这两个老演员身上,我结结实实地摔了三个跟头。

今天就把这段“反复被拷打”的真实踩坑记录写下来,希望能帮到同样在写 CRUD 的小伙伴们!

🚨 第一回合:单字段唯一索引的“坑”

需求很简单:角色名称(role_name)必须唯一,并且要求使用逻辑删除(is_deleted,0代表正常,1代表删除)。

刚上手,我微微一笑,这还不简单? 我直接给表里的 role_name 加上了 UNIQUE 唯一索引,跑通了新增接口,美滋滋。

然后自己测试的时候,我给自己上了一课:

  1. 我新建了一个角色叫【财务】(此时 is_deleted = 0,完美)。

  2. 我把【财务】删除了(触发逻辑删除,is_deleted = 1,也没毛病)。

  3. 核弹来了: 我想重新建一个叫【财务】的角色。

直接报错 DuplicateKeyException!💥 因为数据库虽然逻辑删除了,但【财务】这个名字还在表里,触发了 role_name 的单字段唯一约束。

🤔 第二回合:自作聪明的“联合唯一索引”

痛定思痛,我灵机一动:既然单字段不行,那我建个联合索引呗! 我把唯一索引改成了:UNIQUE KEY (role_name, is_deleted)

我当时在心里疯狂推演:

  1. 新建【财务】:('财务', 0) -> 成功!

  2. 删除【财务】:('财务', 1) -> 成功!

  3. 再次新建【财务】:('财务', 0) -> 和 ('财务', 1) 不冲突,成功!

当时我觉得自己是个天才。然而,多点了几下后,又翻车了:

  1. 按照上面的步骤,我现在数据库里有两条记录:一条是被删的 ('财务', 1),一条是刚新建的 ('财务', 0)

  2. 致命操作: 我把这个刚建好的新【财务】又给删了!

系统再次报错 DuplicateKeyException!💥 因为我试图把第二条新记录也更新成 is_deleted = 1,而数据库里已经存在一条 ('财务', 1) 了,联合索引直接拒绝了我的第二次删除。

💡 终极回合:顿悟!让主键 ID 拯救世界

盯着屏幕抓头发的我,突然想到一个绝妙的思路: 既然 ('财务', 1) 会冲突,那我只要保证每次删除时,产生的值都不一样就行了!

那这张表里什么东西是绝对不可能重复的?主键 ID 啊!

我决定彻底抛弃用 1 代表删除的做法,改为:

  • 正常数据: is_deleted = 0

  • 已删除数据: is_deleted = 它自己的主键 ID

按照这个思路重新推演:

  1. 新建: 插入 id=1, role_name='财务', is_deleted=0

  2. 删除:is_deleted 更新为它自己的 id。数据变为 id=1, role_name='财务', is_deleted=1

  3. 再次新建: 插入 id=2, role_name='财务', is_deleted=0

  4. 再次删除:is_deleted 更新为它自己的 id。数据变为 id=2, role_name='财务', is_deleted=2

行云流水!没有任何冲突死角,数据库层面干干净净!

💻 落地实战:MyBatis-Plus 的代码实现

思路理顺了,下面放上我项目里的真实代码,看看用 MyBatis-Plus 怎么落地这个思路。

1. 核心操作:优雅的手动删除接口

因为 MP 自带的删除只能把字段更新成死值(比如 1),所以我自己用 LambdaUpdateWrapper 手写了底层的赋值逻辑。

    /**
     * 删除角色 (核心踩坑点解决!)
     *
     * @param id 角色id
     * @return 是否删除成功
     */
    @Override
    public boolean deleteSysRoleByLogic(Long id) {
        if (id < 1) {
            throw new BusinessException(ResultCodeEnum.PARAM_ERROR);
        }
        // 查一下确保数据还在
        SysRole sysRole = sysRoleMapper.selectById(id);
        if (sysRole == null) {
            throw new BusinessException(ResultCodeEnum.BUSINESS_ERROR, "角色不存在");
        }
        
        // 划重点!!!
        LambdaUpdateWrapper<SysRole> updateWrapper = new LambdaUpdateWrapper<>();
        // 骚操作:直接利用 SQL 让数据库自己把 is_deleted 赋值为自身的 id
        updateWrapper.setSql("is_deleted = id")
                .eq(SysRole::getId, id);
                
        int update = sysRoleMapper.update(sysRole, updateWrapper);
        return update > 0;
    }

这句 .setSql("is_deleted = id") 就是整套方案的灵魂。

2. 新增与查询:白嫖 MP 的快乐

解决了删除,新增和查询就彻底变成了体力活。只要实体类 SysRoleisDeleted 字段配上了 @TableLogic(value = "0"),剩下的就是白嫖框架。

新增角色: 新增后顺手返回新生成的 ID,方便前端拿去分配权限啥的。

    /**
     * 添加角色
     *
     * @param sysRoleAddDto 角色实体对象
     * @return 是否添加成功
     */
    @Override
    public Long addSysRole(SysRoleAddDto sysRoleAddDto) {
        String roleName = sysRoleAddDto.getRoleName();
        if (!StringUtils.hasText(roleName)) {
            throw new BusinessException(ResultCodeEnum.PARAM_ERROR, "角色名称不能为空");
        }
        SysRole sysRole = new SysRole();
        BeanUtils.copyProperties(sysRoleAddDto, sysRole);
        sysRoleMapper.insert(sysRole);
        return sysRole.getId();
    }

查询列表: 查询时,压根不用管 is_deleted,MP 会自动在底层拼上 WHERE is_deleted = 0。那些值为 ID 的删除数据,连我们 Java 内存的门都进不来。

    /**
     * 查询角色列表
     *
     * @param sysRoleQueryDto 角色查询参数
     * @return 角色列表
     */
    @Override
    public IPage<SysRoleVo> querySysRole(SysRoleQueryDto sysRoleQueryDto) {
        String roleName = sysRoleQueryDto.getRoleName();
        //获取分页对象
        Page<SysRole> mpPage = sysRoleQueryDto.toMpPage();
        LambdaQueryWrapper<SysRole> queryWrapper = new LambdaQueryWrapper<>();
        
        // 只需要关注业务条件,不需要手写 is_deleted = 0
        queryWrapper.like(StringUtils.hasText(roleName), SysRole::getRoleName, roleName);
        
        Page<SysRole> sysRolePage = sysRoleMapper.selectPage(mpPage, queryWrapper);
        
        // DTO转VO,脱敏处理
        List<SysRoleVo> sysRoleVos = sysRolePage.getRecords().stream()
                .map(sysRole -> {
                    SysRoleVo sysRoleVo = new SysRoleVo();
                    BeanUtils.copyProperties(sysRole, sysRoleVo);
                    return sysRoleVo;
                }).toList();
                
        Page<SysRoleVo> sysRoleVoPage = new Page<>(sysRolePage.getCurrent(), sysRolePage.getSize(), sysRolePage.getTotal());
        sysRoleVoPage.setRecords(sysRoleVos);
        return sysRoleVoPage;
    }

📝 写在最后

做完这个模块最大的感触就是:开发中如果顺着一条路走死了(比如死磕联合索引的 0 和 1),不妨跳出固有的思维框架,利用现成的天然属性(比如主键 ID 的唯一性)去进行降维打击。

这套 is_deleted = id 的解法,代码改动极小,逻辑却严丝合缝,再也不怕自己胡乱测试搞出莫名其妙的 Bug 了。

SpringBoot 3 整合 MyBatis-Plus 3.5.17 踩坑记录:IService 包路径找不到怎么办? 2026-07-18

评论区