最近在做自己的 OA 系统项目,写到 RBAC(基于角色的访问控制)权限模块时。本来以为给角色写个增删改查(CRUD)就是分分钟的事,结果在“唯一索引”和“逻辑删除”这两个老演员身上,我结结实实地摔了三个跟头。
今天就把这段“反复被拷打”的真实踩坑记录写下来,希望能帮到同样在写 CRUD 的小伙伴们!
🚨 第一回合:单字段唯一索引的“坑”
需求很简单:角色名称(role_name)必须唯一,并且要求使用逻辑删除(is_deleted,0代表正常,1代表删除)。
刚上手,我微微一笑,这还不简单? 我直接给表里的 role_name 加上了 UNIQUE 唯一索引,跑通了新增接口,美滋滋。
然后自己测试的时候,我给自己上了一课:
我新建了一个角色叫【财务】(此时
is_deleted = 0,完美)。我把【财务】删除了(触发逻辑删除,
is_deleted = 1,也没毛病)。核弹来了: 我想重新建一个叫【财务】的角色。
直接报错 DuplicateKeyException!💥 因为数据库虽然逻辑删除了,但【财务】这个名字还在表里,触发了 role_name 的单字段唯一约束。
🤔 第二回合:自作聪明的“联合唯一索引”
痛定思痛,我灵机一动:既然单字段不行,那我建个联合索引呗! 我把唯一索引改成了:UNIQUE KEY (role_name, is_deleted)。
我当时在心里疯狂推演:
新建【财务】:
('财务', 0)-> 成功!删除【财务】:
('财务', 1)-> 成功!再次新建【财务】:
('财务', 0)-> 和('财务', 1)不冲突,成功!
当时我觉得自己是个天才。然而,多点了几下后,又翻车了:
按照上面的步骤,我现在数据库里有两条记录:一条是被删的
('财务', 1),一条是刚新建的('财务', 0)。致命操作: 我把这个刚建好的新【财务】又给删了!
系统再次报错 DuplicateKeyException!💥 因为我试图把第二条新记录也更新成 is_deleted = 1,而数据库里已经存在一条 ('财务', 1) 了,联合索引直接拒绝了我的第二次删除。
💡 终极回合:顿悟!让主键 ID 拯救世界
盯着屏幕抓头发的我,突然想到一个绝妙的思路: 既然 ('财务', 1) 会冲突,那我只要保证每次删除时,产生的值都不一样就行了!
那这张表里什么东西是绝对不可能重复的?主键 ID 啊!
我决定彻底抛弃用 1 代表删除的做法,改为:
正常数据:
is_deleted = 0已删除数据:
is_deleted = 它自己的主键 ID
按照这个思路重新推演:
新建: 插入
id=1, role_name='财务', is_deleted=0。删除: 把
is_deleted更新为它自己的 id。数据变为id=1, role_name='财务', is_deleted=1。再次新建: 插入
id=2, role_name='财务', is_deleted=0。再次删除: 把
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 的快乐
解决了删除,新增和查询就彻底变成了体力活。只要实体类 SysRole 的 isDeleted 字段配上了 @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 了。