RBAC 之用户管理
- 创建表(Entity)
- 用户表
- 角色表
- 权限表
- 用户角色表
- 关系注解
- @ManyToMany
- 角色权限表
- 接口开发
- UserController
- UserService
- UserServiceImpl
- UserRepository
- 问题解决
- update 更新问题
- 懒加载问题
- JSON 循环依赖问题
根据上一小结对表的设计,我们开始编写代码实现权限管理模块的代码。
我们直接通过编写实体映射类,让JPA自动帮我们生成一下数据库表。
创建表(Entity)
首先通过 JPA 将需要用到的5张表都建好。
先将基础实体类修改一下
@Data
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Schema(description = "基础实体")
public class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Schema(description = "用户ID")
private Long id;
@Column(columnDefinition = "tinyint(1) NOT NULL COMMENT '状态'")
@Schema(description = "状态")
private Integer status;
@Column(columnDefinition = "bit NOT NULL COMMENT '0正常,1删除'")
@Schema(description = "逻辑删除")
private Boolean isDelete;
@CreatedBy
@Column(updatable = false, columnDefinition = "varchar(32) COMMENT '创建用户'")
@Schema(description = "创建用户", hidden = true)
private String createUser;
@LastModifiedBy
@Column(columnDefinition = "varchar(32) COMMENT '修改用户'")
@Schema(description = "修改用户", hidden = true)
private String updateUser;
@CreatedDate
@Column(updatable = false, columnDefinition = "datetime COMMENT '创建时间'")
@Schema(description = "创建时间", hidden = true)
private Date createTime;
@LastModifiedDate
@Column(columnDefinition = "datetime COMMENT '修改时间'")
@Schema(description = "修改时间", hidden = true)
private Date updateTime;
}
将一些每个表都存在的共有属性抽离出来当作一个父类,需要这些字段的实体通过继承获得这些字段属性。
@MappedSuperclass
:标注了@MappedSuperclass的类将不是一个完整的实体类,他将不会映射到数据库表,但是他的属性都将映射到其子类的数据库字段中。
@Column
:用来标识实体类中属性与数据表中字段的对应关系。
updatable
:updatable属性表示 在使用“UPDATE”脚本插入数据时,是否需要更新该字段值。
columnDefinition
:表示创建表时,该字段创建的SQL语句,一般用于通过Entity生成表定义时使用。
用户表
先看一下表结构:
字段 | 类型 | 含义 |
---|---|---|
id | bigint(20) | 主键ID |
name | varchar(32) | 用户名 |
mobile | char(11) | 手机号 |
avatar | varchar(255) | 头像 |
varchar(50) | 邮箱 | |
password | varchar(12) | 密码 |
status | tinyint(1) | 状态:0正常,1锁定 |
is_delete | bit | 0删除,1未删除 |
last_login_time | datetime | 最后登录时间 |
create_time | datetime | 创建时间 |
create_user | varchar(32) | 创建用户 |
update_time | datetime | 更新时间 |
update_user | varchar(32) | 更新用户 |
UserEntity:
@Data
@Entity
@Table(name = "sys_user")
@Schema(description = "用户实体")
public class UserEntity extends BaseEntity implements Serializable {
@Column(unique = true, columnDefinition = "varchar(32) NOT NULL COMMENT '名称'")
@Schema(description = "用户名称")
private String name;
@Column(columnDefinition = "char(11) COMMENT '手机号'")
@Schema(description = "手机号")
private String mobile;
@Column(columnDefinition = "varchar(255) COMMENT '头像'")
@Schema(description = "头像")
private String avatar;
@Column(columnDefinition = "varchar(50) COMMENT '邮箱'")
@Schema(description = "用户邮箱")
private String email;
@Column(columnDefinition = "varchar(12) NOT NULL COMMENT '密码'")
@Schema(description = "用户密码")
private String password;
@Column(columnDefinition = "datetime COMMENT '最后登录时间'")
@Schema(description = "最后登录时间")
private Date lastLoginTime;
}
角色表
表结构:
字段 | 类型 | 含义 |
---|---|---|
id | bigint(20) | 主键ID |
name | varchar(32) | 角色名称 |
remark | varchar(255) | 备注 |
status | tinyint(1) | 状态 |
is_delete | bit | 0删除,1未删除 |
create_time | datetime | 创建时间 |
create_user | varchar(32) | 创建用户 |
update_time | datetime | 更新时间 |
update_user | varchar(32) | 更新用户 |
RoleEntity:
@Data
@Entity
@Table(name = "sys_role")
@Schema(description = "角色实体")
public class RoleEntity extends BaseEntity implements Serializable {
@Column(unique = true, columnDefinition = "varchar(32) NOT NULL COMMENT '角色名称'")
@Schema(description = "角色名称")
private String name;
@Column(columnDefinition = "varchar(500) COMMENT '备注'")
@Schema(description = "备注")
private String remark;
}
权限表
表结构:
字段 | 类型 | 含义 |
---|---|---|
id | bigint(20) | 主键ID |
pid | bigint(20) | 父菜单ID,一级菜单为0 |
name | varchar(32) | 菜单名称 |
url | varchar(255) | 菜单URL |
perms | varchar(500) | 授权(多个用逗号分隔,如:user:list,user:create) |
type | tinyint(1) | 类型: 0目录, 1菜单, 2按钮 |
icon | varchar(50) | 菜单图标 |
sort | tinyint(2) | 排序 |
status | tinyint(1) | 状态 |
is_delete | bit | 0删除,1未删除 |
create_time | datetime | 创建时间 |
create_user | varchar(32) | 创建用户 |
update_time | datetime | 更新时间 |
update_user | varchar(32) | 更新用户 |
MenuEntity:
@Data
@Entity
@Table(name = "sys_menu")
@Schema(description = "菜单实体")
public class MenuEntity extends BaseEntity implements Serializable {
@Column(columnDefinition = "bigint NOT NULL COMMENT '父菜单ID'")
@Schema(description = "父菜单ID")
private Long pid;
@Column(unique = true, columnDefinition = "varchar(32) NOT NULL COMMENT '菜单名称'")
@Schema(description = "菜单名称")
private String name;
@Column(columnDefinition = "varchar(255) COMMENT '菜单url'")
@Schema(description = "url")
private String url;
@Column(columnDefinition = "varchar(500) COMMENT '授权(多个用逗号分隔,如:user:list,user:create)'")
@Schema(description = "授权(多个用逗号分隔,如:user:list,user:create)")
private String perms;
@Column(columnDefinition = "tinyint(1) NOT NULL COMMENT '菜单类型:0目录,1菜单,2按钮'")
@Schema(description = "菜单类型:0目录,1菜单,2按钮")
private Byte type;
@Column(columnDefinition = "varchar(50) COMMENT '菜单图标'")
@Schema(description = "菜单图标")
private String icon;
@Column(columnDefinition = "tinyint(2) COMMENT '排序'")
@Schema(description = "排序")
private Integer sort;
}
编写好3个entity,启动程序查看建表是否成功。控制台打印如下建表语句,同时查看数据库,发现表创建没有问题。
Hibernate:
create table sys_menu (
id bigint not null auto_increment,
create_time datetime COMMENT '创建时间',
create_user varchar(32) COMMENT '创建用户',
is_delete bit NOT NULL COMMENT '0正常,1删除',
status tinyint(1) NOT NULL COMMENT '状态',
update_time datetime COMMENT '修改时间',
update_user varchar(32) COMMENT '修改用户',
icon varchar(50) COMMENT '菜单图标',
name varchar(32) NOT NULL COMMENT '菜单名称',
perms varchar(500) COMMENT '授权(多个用逗号分隔,如:user:list,user:create)',
pid bigint NOT NULL COMMENT '父菜单ID',
sort tinyint(2) COMMENT '排序',
type tinyint(1) NOT NULL COMMENT '菜单类型:0目录,1菜单,2按钮',
url varchar(255) COMMENT '菜单url',
primary key (id)
) engine=InnoDB
Hibernate:
create table sys_role (
id bigint not null auto_increment,
create_time datetime COMMENT '创建时间',
create_user varchar(32) COMMENT '创建用户',
is_delete bit NOT NULL COMMENT '0正常,1删除',
status tinyint(1) NOT NULL COMMENT '状态',
update_time datetime COMMENT '修改时间',
update_user varchar(32) COMMENT '修改用户',
name varchar(32) NOT NULL COMMENT '角色名称',
remark varchar(500) COMMENT '备注',
primary key (id)
) engine=InnoDB
Hibernate:
create table sys_user (
id bigint not null auto_increment,
create_time datetime COMMENT '创建时间',
create_user varchar(32) COMMENT '创建用户',
is_delete bit NOT NULL COMMENT '0正常,1删除',
status tinyint(1) NOT NULL COMMENT '状态',
update_time datetime COMMENT '修改时间',
update_user varchar(32) COMMENT '修改用户',
avatar varchar(255) COMMENT '头像',
email varchar(50) COMMENT '邮箱',
last_login_time datetime COMMENT '最后登录时间',
mobile char(11) COMMENT '手机号',
name varchar(32) NOT NULL COMMENT '名称',
password varchar(12) NOT NULL COMMENT '密码',
primary key (id)
) engine=InnoDB
同时还为name字段添加了索引,因为我们给name字段添加了 unique=true
表示name字段为唯一,jpa 会为这个字段添加索引。
Hibernate:
alter table sys_menu
add constraint UK_4kk1vl4bvpaho8ked9v4xkr9d unique (name)
Hibernate:
alter table sys_role
add constraint UK_bqy406dtsr7j7d6fawi1ckyn1 unique (name)
Hibernate:
alter table sys_user
add constraint UK_iic0kskryiymn15fg9bqpmw22 unique (name)
用户角色表
使用实体映射来创建单表很好理解,但是怎么创建关联表呢,我们可以通过Spring Data JPA关系映射中对表关系定义的注解来创建表的关系。
关系注解
注解 | 说 |
---|---|
@OneToOne | 定义表之间“一对一”的关系。 |
@OneToMany | 定义表之间“一对多”的关系。 |
@ManyToOne | 定义表之间“多对一”的关系。 |
@ManyToMany | 定义表之间“多对多”的关系。 |
@ManyToMany
我们之前分析了,我们的表之间的关系都是多对多的关系,所以我们使用 @ManyToMany
来定义表之间的关系,ManyToMany总是会使用中间关系连接表来存储关系,Jpa 会自动帮我们创建中间关联表。
@ManyToMany 注解用来定义具有多对多多重性的多值关联。
每个多对多关系都有两个方面,关系的拥有方和非拥有方。连接表(中间表)在关系的拥有方指定。
如果关联是双向的,任何一方都可以被指定为关系的拥有方。
如果关系是双向的,则关系的非拥有方必须使用 @ManyToMany 注解的 mappingBy 属性来指定拥有方的关系字段或属性。
user表作为主表,在 UserEntity 中添加如下字段
@ManyToMany
@JoinTable(name = "sys_user_role",
joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id")},
inverseJoinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id")})
@Schema(description = "用户角色")
private List<RoleEntity> roles;
@JoinTable:@JoinTable 注解用于关联映射,它是在关联的拥有方进行配置。使用 @JoinTable 注解将创建一个连接表,也称为“中间表”。
name:中间连接表名称(sys_user_role),默认是主表_副表(user_role)
joinColumns:连接表主表外键(user_id)
inverseJoinColumns:连接表副表外键(role_id)
role表作为副表,需要使用@ManyToMany 注解的 mappingBy 属性来指定拥有方的关系字段或属性。
@ManyToMany(mappedBy = "roles")
@Schema(description = "角色包含的用户")
private List<UserEntity> users;
**mappedBy:**拥有关系的字段,单向关系不需要指定改属性;双向关系必须指定改属性的值。
添加好两个字段,启动看看是否能够成功创建表,控制台打印了如下sql,没有爆出表明表已经成功。
角色权限表
这个和用户角色表一模一样,直接上代码
@ManyToMany
@JoinTable(name = "sys_role_menu",
joinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id")},
inverseJoinColumns = {@JoinColumn(name = "menu_id", referencedColumnName = "id")})
@Schema(description = "角色菜单")
private List<MenuEntity> menus;
@ManyToMany(mappedBy = "menus")
@Schema(description = "菜单包含的角色")
private List<RoleEntity> roles;
接口开发
先来梳理一下我们有哪些接口需要开发,现在只有 CRUD,后面根据需求慢慢加。
- 分页查询用户
- 根据名称查询用户
- 根据ID查询用户
- 新增|修改用户(批量)
- 删除用户
直接上代码了,毕竟都是简单的crud,容易出错的部分拿出讲一下就好了
UserController
@Tag(name = "用户管理")
@RestController
@RequestMapping("/sys/user")
public class UserController {
@Resource
private UserService userService;
@GetMapping("/page")
@Operation(summary = "分页查询")
public Page<UserEntity> page(int page, int size) {
return userService.page(PageRequest.of(page - 1, size));
}
@GetMapping("/{id}")
@Operation(summary = "根据用户ID查询用户")
public UserEntity get(@PathVariable Long id) {
return userService.get(id);
}
@GetMapping("/name/{name}")
@Operation(summary = "根据用户名称查询用户")
public UserEntity getByName(@PathVariable String name) {
return userService.getByName(name);
}
@PostMapping
@Operation(summary = "新增|修改用户")
public void upsert(@RequestBody List<UserEntity> users) {
userService.upsert(users);
}
@DeleteMapping("/{id}")
@Operation(summary = "根据用户ID删除用户")
public void delete(@PathVariable Long id) {
userService.delete(id);
}
}
UserService
public interface UserService {
/**
* 分页查询用户
*
* @param pageable 分页参数
* @return Page<UserEntity> 分页用户
*/
Page<UserEntity> page(Pageable pageable);
/**
* 根据ID查询用户
*
* @param id 用户ID
* @return UserEntity 用户信息
*/
UserEntity get(Long id);
/**
* 根据名称查询用户
*
* @param name 用户名称
* @return UserEntity 用户信息
*/
UserEntity getByName(String name);
/**
* 保存/更新用户
*
* @param users 用户信息
*/
void upsert(List<UserEntity> users);
/**
* 删除用户
*
* @param id 用户id
*/
void delete(Long id);
}
UserServiceImpl
@Service
public class UserServiceImpl implements UserService {
@Resource
private UserRepository userRepository;
@Override
public Page<UserEntity> page(Pageable pageable) {
return userRepository.findAll(pageable);
}
@Override
public UserEntity get(Long id) {
return userRepository.findById(id).orElse(null);
}
@Override
public UserEntity getByName(String name) {
return userRepository.findByName(name);
}
@Override
public void upsert(List<UserEntity> users) {
userRepository.saveAll(users);
}
@Override
public void delete(Long id) {
userRepository.deleteById(id);
}
}
UserRepository
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long>, Serializable {
/**
* 根据用户名称查询用户
* 这个相当与一个公式, findBy字段名称,jpa 就会自动实现查询,不用我们再写查询逻辑
*
* @param name 用户名称
* @return 用户信息
*/
UserEntity findByName(String name);
}
简单的接口开发完了之后,其实还是存在了3个问题,接下来我们一一来解决。
问题解决
update 更新问题
jpa 中没有单独的 update 方案,他是通过 save 方法来进行更新的,如果 id 为空就是新增,不为空就是修改。
我们先通过新增方法新增一条数据,请求数据如下:
[
{
"status": 0,
"isDelete": true,
"name": "金克斯",
"mobile": "11011101111",
"avatar": "",
"email": "xm@xm.com",
"password": "123456",
"lastLoginTime": "2023-12-12 12:12:12"
}
]
然后假设我们现在想修改密码为:666666,请求体如下:
[
{
"id": 1,
"password": "666666"
}
]
按理来说没有如何问题对吧,但是一般这个时候就有问题了,请看VCR。
我们发现请求失败,返回值500,这肯定是后端报错了。
{
"code": 500,
"message": "系统异常,请稍后重试",
"body": null
}
查看控制台日志,发现报错信息如下
java.sql.SQLIntegrityConstraintViolationException: Column 'is_delete' cannot be null
说的是字段 is_delete 不能为空,这是数据库的校验,但是我们没有修改这个字段呀,继续看打印的sql
select
ue1_0.id,
ue1_0.avatar,
ue1_0.create_time,
ue1_0.create_user,
ue1_0.email,
ue1_0.is_delete,
ue1_0.last_login_time,
ue1_0.mobile,
ue1_0.name,
ue1_0.password,
ue1_0.status,
ue1_0.update_time,
ue1_0.update_user
from
sys_user ue1_0
where
ue1_0.id=?
update
sys_user
set
avatar=?,
email=?,
is_delete=?,
last_login_time=?,
mobile=?,
name=?,
password=?,
status=?,
update_time=?,
update_user=?
where
id=?
ok,答案出来了,jpa 默认更新全部字段,先从数据库查询出这条数据,然后更新全部数据,这可不行,这也太费劲了,我们需要更新我们像更新的字段就行呀,这个可以通过在实体类上添加@DynamicUpdate注解,表示动态更新查询。
@Data
@Entity
@DynamicUpdate
@Table(name = "sys_user")
@Schema(description = "用户实体")
public class UserEntity extends BaseEntity implements Serializable {
重启测试一下,但是发现还是不行,还是更新了全部数据,这是因为jpa会把属性值为null也当成是修改,意思就是当我们没有传入字段时,比如此时 isDelete=null ,但是jpa以为我们要把这个 null 更新到数据库, 这个怎么解决呢,我们自己写一个工具类将属性值为null的数据过滤掉在进行 save 即可,是不是感觉很麻烦。。。
编写一个 JpaUtils:
public class JpaUtils {
/**
* 从 src 中复制不为 null 的字段到 target
*
* @param src 源实体
* @param target 目标实体
*/
public static void copyNotNullProperties(Object src, Object target) {
BeanUtils.copyProperties(src, target, getNullPropertyNames(src));
}
/**
* 获取实体中为null的属性名称
*
* @param source 实体类
* @return 需要过滤掉的属性名称
*/
public static String[] getNullPropertyNames(Object source) {
final BeanWrapper src = new BeanWrapperImpl(source);
return Arrays.stream(src.getPropertyDescriptors())
// 获取每一个属性名称
.map(FeatureDescriptor::getName)
// 过滤掉属性值不为null的字段
.filter(name -> src.getPropertyValue(name) == null)
.toArray(String[]::new);
}
}
修改实现类中的upsert方法
@Override
public void upsert(List<UserEntity> users) {
users.forEach(user -> {
Optional<UserEntity> userEntity = userRepository.findById(user.getId());
if (userEntity.isPresent()) {
// 修改
JpaUtils.copyNotNullProperties(user, userEntity.get());
userRepository.save(userEntity.get());
} else {
// 新增
userRepository.save(user);
}
});
}
ok,到此这个问题解决。
懒加载问题
我们在查询用户时,会抛出异常
Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: failed to lazily initialize a collection of role: com.xm.module.sys.entity.UserEntity.roles: could not initialize proxy - no Session]
意思是获取 roles 的时候是懒加载的,但是懒加载失败,因为找不到session,那我们就修改为及时加载
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "sys_user_role",
joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id")},
inverseJoinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id")})
@Schema(description = "用户角色")
private List<RoleEntity> roles;
**fetch:**关联是应该延迟加载还是必须马上加载。EAGER 策略表示必须马上获取关联的实体,LAZY 策略表示用到关联对象时才去加载。默认值为 javax.persistence.FetchType.LAZY
JSON 循环依赖问题
我们先为用户关联上一个角色,然后再去查询该用户信息,会显示这个,这是因为循环依赖导致返回数据非常大,因为我们的 UserEntity 中包含了 RoleEntity,而 RoleEntity 中有包含有 UserEntity,就会导致这个问题。
后台报错:
Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Infinite recursion (StackOverflowError)]
解决方案:
在副表的字段上添加@JsonIgnore
注解,表示序列化的时候忽略这个子段。
@JsonIgnore
@ManyToMany(mappedBy = "roles")
@Schema(description = "角色包含的用户")
private List<UserEntity> users;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "sys_role_menu",
joinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id")},
inverseJoinColumns = {@JoinColumn(name = "menu_id", referencedColumnName = "id")})
@Schema(description = "角色菜单")
private List<MenuEntity> menus;
最后解决掉这些问题,查询OK,这个JPA的坑还是有点多,如果不清楚他的原理,很容易冒出一些奇怪的问题。