通用权限系统的实现

1.RBAC模型

1.1.概述

在企业系统中,通过配置用户的功能权限可以解决不同的人分管不同业务的需求,基于RBAC模型,RBAC(Role Based Access Control)模型,它的中文是基于角色的访问控制,主要是将功能组合成角色,再将角色分配给用户,也就是说角色是功能的合集

比如:

企业A总共有12个功能,需要创建100个用户。这些用户包括财务管理、人事管理、销售管理等等。如果不引入基于角色的访问控制(RBAC)模型,我们就需要每创建一个用户就要分配一次功能,这将至少需要进行100次操作(每个用户只能拥有一个功能)。如果用户数量增加到1000甚至10000,并且一个用户可能会拥有多个功能,操作将会变得非常繁琐。如图:

 经过多次操作后发现,有些人被分配了相同的功能。例如,A、B等10个用户都被分配了客户管理、订单管理和供应商管理这几个模块。我们是否可以将这几个功能模块组合成一个包,然后将整个包分配给需要的用户呢?这个包被称为角色。由于角色和功能之间的对应关系相对稳定,在分配权限时只需分配角色即可,如下图所示:如图所示:

基于RBAC授权模式后,我们可以达到以下2个目标:

  • 解耦用户和功能,降低操作错误率

  • 降低功能权限分配的繁琐程度

 

1.2.ER图与关系梳理

在一个核心业务系统中,我们通常通过业务分析,从而抽离出数据库表,表确定之后我们会进一步分析表中应该有的字段,下面我先看下业务ER图:

上图中清楚的描述用户、角色、资源、职位、部门之间的关系,同时我们进一步推导出以下结果:

  • 用户与职位是N:1关系

  • 用户与部门是N:1关系

  • 用户与角色是N:N关系,则它们之间必然有一个中间表

  • 角色与资源是N:N关系,则它们之间必然有一个中间表

具体的表字段大家可以查看提供的数据库,我们在做业务的时候,会再次梳理这些表中的字段

2.需求开发-菜单管理

菜单管理是指对一个系统或应用程序中的菜单进行管理和配置的过程。通过菜单管理,可以定义和组织系统中的菜单项,包括菜单的层级结构、显示名称、图标、链接等内容。这样可以方便用户在系统中进行导航和操作。

2.1.需求分析

原型导航:

2.2.表结构详细说明

表中parent_resource_no和resource_no是用于构建树形结构的基础,通过这2个字段定义资源的上下级关系,通常添加资源菜单,我们通过程序自动生成编号,生成的编号满足以下规则:

  • 1级:100000000000000

  • 2级:100001000000000

  • 3级:100001001000000

  • 4级:100001001001000

  • 5级:100001001001001

当我们在需要查询当前1级节点以下所有节点时,就不用再递归查询,使用like "resource_no%"即可。

举个例子:

想要查询100001001000000下所有的资源,我们的查询方式为:

select * from sys_resource where resource_no like '100001001%'

这样就可以查询到100001001000000资源下所有的子资源了

 自关联

需要完成的接口包含:

  • 资源列表

  • 资源树形菜单

  • 资源添加

  • 资源修改

  • 启用禁用

  • 删除资源菜单

2.3.功能实现

2.3.1.资源列表

2.3.1.1.重要工具类说明

在common模块下提供了一个工具类NoProcessing,其中有两个方法:

package com.zzyl.utils;

/**
 *  工具类补全
 */
public class NoProcessing {

    /***
     *  处理补全编号,所有编号都是15位,共5层,每层关系如下:
     * 第一层:100000000000000
     * 第二层:100001000000000
     * 第三层:100001001000000
     * 第四层:100001001001000
     * 第五层:100001001001001
     * @param input 如果为001000000000000处理完成后则变为001
     * @return
 * @return: java.lang.String
     */
    public static String processString(String input) {
        int step = input.length() / 3;
        for (int i =0;i<step;i++ ){
            String targetString = input.substring(input.length()-3,input.length());
            if ("000".equals(targetString)){
                input = input.substring(0,input.length()-3);
            }else {
                break;
            }
        }
        return input;
    }

    public static void main(String[] args) {
        String input = "100001000000000";
        String processedString = createNo(input,true);
        System.out.println(processedString);
    }

    /***
     *  生产层级编号
     * @param input 输入编号
     * @param peerNode 是否下属节点
     * @return
     * @return: java.lang.String
     */
    public static String createNo(String input,boolean peerNode) {
        int step = input.length() / 3;
        int supplement = 0;
        for (int i =0;i<step;i++ ){
            String targetString = input.substring(input.length()-3,input.length());
            if ("000".equals(targetString)){
                input = input.substring(0,input.length()-3);
                supplement++;
            }else {
                break;
            }
        }
        if (peerNode){
            input = String.valueOf(Long.valueOf(input) + 1L);
            for (int i =0;i<supplement;i++ ){
                input = input+"000";
            }
        }else {
            input = String.valueOf(Long.valueOf(input+"001"));
            for (int i =0;i<supplement-1;i++ ){
                input = input+"000";
            }
        }
        return input;
    }

}
  • processString(String):去除输入字符串末尾连续的 "000" 子串

    • 原始值:100001000000000,处理后: 100001

    • 原始值:100001010000000,处理后: 100001010

  • createNo(String,boolean):根据输入父资源编号生成一个新的编号

       处理后的结果:处理后: 100001001000000

       处理后: 100002000000000

    • 原始值:100001000000000,false:表示该编号没有子编号 ,根据当前编号生成子编号

    • 原始值:100001000000000,true:表示该编号有子编号,在当前编号的基础上进行追加

 2.3.1.2.接口定义
/**
 * 资源前端控制器
 */
@Slf4j
@Api(tags = "资源管理")
@RestController
@RequestMapping("/resource")
public class ResourceController {


    @PostMapping("/list")
    @ApiOperation(value = "资源列表", notes = "资源列表")
    @ApiImplicitParam(name = "resourceDto", value = "资源DTO对象", required = true, dataType = "ResourceDto")
    @ApiOperationSupport(includeParameters = {"resourceDto.parentResourceNo", "resourceDto.resourceType"})
    public ResponseResult<List<ResourceVo>> resourceList(@RequestBody ResourceDto resourceDto) {
       List<ResourceVo> resourceVoList = resourceService.findResourceList(resourceDto);
    return ResponseResult.success(resourceVoList);
    }

}
  • ApiOperationSupport是可以在swagger接口文档中说明参数包含了哪些字段,比如你定义的dto是10个字段,但是当前接口只需要6个字段,就可以把这6个字段声明出来,这样做的好处就是dto可以在多个接口中复用

 2.3.1.3.mapper持久层

在ResourceMapper中新增方法,无需分页查询

List<Resource> selectList(ResourceDto resourceDto);

对应的映射文件

<select id="selectList" resultMap="BaseResultMap">
    select
    <include refid="Base_Column_List"></include>
    from sys_resource
    <where>
        <if test="resourceType != null and resourceType != ''">
            and resource_type = #{resourceType}
        </if>
        <if test="parentResourceNo != null and parentResourceNo != ''">
            and parent_resource_no like concat(#{parentResourceNo},'%')
        </if>
        <if test="dataState != null and dataState != ''">
            and data_state = #{dataState}
        </if>
    </where>
    order by sort_no asc
</select>
2.3.1.4.业务层

新增ResourceService 业务层接口,定义查询列表方法

/**
 * 权限表服务类
 */
public interface ResourceService {


    /**
     * 多条件查询资源列表
     * @param resourceDto
     * @return
     */
    List<ResourceVo> findResourceList(ResourceDto resourceDto);
    
}

实现类:

/**
 * 权限表服务实现类
 */
@Service
public class ResourceServiceImpl implements ResourceService {

    @Autowired
    private ResourceMapper resourceMapper;

    /**
     * 多条件查询资源列表
     *
     * @param resourceDto
     * @return
     */
    @Override
    public List<ResourceVo> findResourceList(ResourceDto resourceDto) {
        List<Resource> resourceList = resourceMapper.selectList(resourceDto);
        return BeanUtil.copyToList(resourceList, ResourceVo.class);
    }

}

2.3.1.5.测试

启动前端项目,找到菜单管理,能看到数据,就算开发成功

2.3.2.资源树形

 在构建树形结构之前,必须先搞清楚接口的返回数据,有助于我们来组装对应的数据结构

 2.3.2.1.接口定义

在ResourceController中定义新的方法,如下:

@PostMapping("/tree")
@ApiOperation(value = "资源树形", notes = "资源树形")
@ApiImplicitParam(name = "resourceDto", value = "资源DTO对象", required = true, dataType = "ResourceDto")
@ApiOperationSupport(includeParameters = {"resourceDto.label"})
public ResponseResult<TreeVo> resourceTreeVo(@RequestBody ResourceDto resourceDto) {
    TreeVo treeVo = resourceService.resourceTreeVo(resourceDto);
    return ResponseResult.success(treeVo);
}
2.3.2.2.mapper持久层
List<Resource> selectList(ResourceDto resourceDto);

 对应的映射文件

<select id="selectList" resultMap="BaseResultMap">
    select
    <include refid="Base_Column_List"></include>
    from sys_resource
    <where>
        <if test="resourceType != null and resourceType != ''">
            and resource_type = #{resourceType}
        </if>
        <if test="parentResourceNo != null and parentResourceNo != ''">
            and parent_resource_no like concat(#{parentResourceNo},'%')
        </if>
        <if test="dataState != null and dataState != ''">
            and data_state = #{dataState}
        </if>
    </where>
    order by sort_no asc
</select>
2.3.2.3.业务层

 在ResourceService中新增方法

/**
 *  资源树形
 * @param resourceDto 查询条件
 * @return: TreeVo
 */
TreeVo resourceTreeVo(ResourceDto resourceDto);

实现方法:

/**
 * 资源树形
 * @param resourceDto 查询条件
 * @return: TreeVo
 */
@Override
public TreeVo resourceTreeVo(ResourceDto resourceDto) {

    //构建查询条件
    ResourceDto dto = ResourceDto.builder().dataState(SuperConstant.DATA_STATE_0)
            .parentResourceNo(NoProcessing.processString(SuperConstant.ROOT_PARENT_ID))
            .resourceType(SuperConstant.MENU)
            .build();

    //查询所有资源数据
    List<Resource> resourceList = resourceMapper.selectList(dto);
    if(EmptyUtil.isNullOrEmpty(resourceList)){
        throw new RuntimeException("资源信息未定义");
    }
    //没有根节点,构建根节点
    Resource rootResource = new Resource();
    rootResource.setResourceNo(SuperConstant.ROOT_PARENT_ID);
    rootResource.setResourceName("智慧养老院");

    //返回的树形集合
    List<TreeItemVo> itemVos = new ArrayList<>();

    //使用递归构建树形结构
    recursionTreeItem(itemVos,rootResource,resourceList);

    //数据返回
    return TreeVo.builder().items(itemVos).build();
}

/**
 * 使用递归构建树形结构
 * @param itemVos
 * @param rootResource
 * @param resourceList
 */
private void recursionTreeItem(List<TreeItemVo> itemVos, Resource rootResource, List<Resource> resourceList) {
    //构建每个资源的属性
    TreeItemVo treeItemVo = TreeItemVo.builder()
            .id(rootResource.getResourceNo())
            .label(rootResource.getResourceName()).build();

    //获取当前资源下子资源
    List<Resource> childrenResourceList = resourceList.stream()
            .filter(n -> n.getParentResourceNo().equals(rootResource.getResourceNo()))
            .collect(Collectors.toList());
    //判断子资源是否为空
    if(!EmptyUtil.isNullOrEmpty(childrenResourceList)){

        List<TreeItemVo> listChildren = new ArrayList<>();
        //构建子资源
        childrenResourceList.forEach(resource -> {
            recursionTreeItem(listChildren,resource,resourceList);
        });
        treeItemVo.setChildren(listChildren);
    }

    itemVos.add(treeItemVo);
}

2.3.2.4.测试

在前端页面中,点击菜单配置,然后在上级菜单中可以看到资源树形接口

 2.3.3.添加资源

2.3.3.1.接口定义 

在ResourceController中定义新的方法

@PutMapping
@ApiOperation(value = "资源添加", notes = "资源添加")
@ApiImplicitParam(name = "resourceDto", value = "资源DTO对象", required = true, dataType = "ResourceDto")
@ApiOperationSupport(includeParameters = {"resourceDto.dataState"
        , "resourceDto.icon"
        , "resourceDto.parentResourceNo"
        , "resourceDto.requestPath"
        , "resourceDto.resourceName"
        , "resourceDto.resourceType"
        , "resourceDto.sortNo"})
public ResponseResult createResource(@RequestBody ResourceDto resourceDto) {
     resourceService.createResource(resourceDto);
    return ResponseResult.success();
}

2.3.3.2.mapper持久层

新增的资源数据的状态要与父级资源的状态保持一致,所以,需要查询父级菜单的状态,需单独定义mapper查询

在ResourceMapper中新增查询方法

@Select("select * from sys_resource where resource_no = #{resourceNo}")
Resource selectByResourceNo(String parentResourceNo);

2.3.3.3.业务层

在ResourceService中新增方法

/**
 * 添加资源菜单
 * @param resourceDto
 * @return
 */
void createResource(ResourceDto resourceDto);

实现方法

/**
 * 添加资源
 * @param resourceDto
 */
@Override
public void createResource(ResourceDto resourceDto) {
    //属性拷贝
    Resource resource = BeanUtil.toBean(resourceDto, Resource.class);
    //查询父资源
    Resource parenResource = resourceMapper.selectByResourceNo(resourceDto.getParentResourceNo());
    resource.setDataState(parenResource.getDataState());
   
    boolean isIgnore = true;
    //判断是否是按钮,如果是按钮,则不限制层级
    if(StringUtils.isNotEmpty(resourceDto.getResourceType())
            && resourceDto.getResourceType().equals(SuperConstant.BUTTON)){
        isIgnore=false;
    }
     //创建当前资源的编号
    String resourceNo = createResourceNo(resourceDto.getParentResourceNo(),isIgnore);
    resource.setResourceNo(resourceNo);

    resourceMapper.insert(resource);

}

/**
 * 创建资源的编号
 * @param parentResourceNo
 * @return
 */
private String createResourceNo(String parentResourceNo,boolean isIgnore) {

    //判断资源编号是否大于三级
    //100 001 000 000 000
    //100 001 001 000 000
    //100 001 001 001 000
    //100 001 001 001 001 001
    if(isIgnore && NoProcessing.processString(parentResourceNo).length() / 3 >= 5){
        throw new BaseException(BasicEnum.RESOURCE_DEPTH_UPPER_LIMIT);
    }

    //根据父资源编号查询子资源
    ResourceDto dto = ResourceDto.builder().parentResourceNo(parentResourceNo).build();
    List<Resource> resources = resourceMapper.selectList(dto);
    if(EmptyUtil.isNullOrEmpty(resources)){
        //无下属节点,创建新的节点编号  100 001 001 001 000--->100 001 001 001 001
        return NoProcessing.createNo(parentResourceNo,false);
    }else {
        //有下属节点,在已有的节点上追加
        //先获取已有节点的最大值--100001003000000
        Long maxNo = resources.stream().map(resource -> {
            return Long.valueOf(resource.getResourceNo());
        }).max(Comparator.comparing(i -> i)).get();

        return NoProcessing.createNo(String.valueOf(maxNo),true);
    }
}

2.3.3.4.测试

在资源菜单中,能够成功保存菜单和按钮

3. 需求开发-角色管理

角色管理的核心作用是实现有效的访问控制和权限管理,确保系统只有经过授权的用户能够访问敏感数据和功能,提高系统的安全性和管理效率

3.1.需求分析

原型导航:

3.2.表结构说明

3.3.接口说明

  • 角色分页查询

  • 角色添加

  • 角色修改

  • 根据角色查询选中的资源数据

  • 删除角色

 3.4.功能实现

3.4.1.角色分页查询

3.4.1.1.接口定义

新创建角色控制类RoleController,定义新的方法,如下:

/**
 * 角色前端控制器
 */
@Slf4j
@Api(tags = "角色管理")
@RestController
@RequestMapping("role")
public class RoleController {

    @PostMapping("page/{pageNum}/{pageSize}")
    @ApiOperation(value = "角色分页",notes = "角色分页")
    @ApiImplicitParams({
        @ApiImplicitParam(name = "roleDto",value = "角色DTO对象",required = true,dataType = "roleDto"),
        @ApiImplicitParam(paramType = "path",name = "pageNum",value = "页码",example = "1",dataType = "Integer"),
        @ApiImplicitParam(paramType = "path",name = "pageSize",value = "每页条数",example = "10",dataType = "Integer")
    })
    @ApiOperationSupport(includeParameters = {"roleDto.roleName"})
    public ResponseResult<PageResponse<RoleVo>> findRoleVoPage(
                                    @RequestBody RoleDto roleDto,
                                    @PathVariable("pageNum") int pageNum,
                                    @PathVariable("pageSize") int pageSize) {
        PageResponse<RoleVo> roleVoPage = roleService.findRolePage(roleDto, pageNum, pageSize);
    return ResponseResult.success(roleVoPage);
    }

}
3.4.1.2.mapper持久层

在RoleMapper中定义分页查询的方法

Page<List<Role>> selectPage(RoleDto roleDto);

xml映射文件:

<select id="selectPage" resultMap="BaseResultMap">
  select
  <include refid="Base_Column_List"/>
  from sys_role
  <where>
    <if test="roleName!=null and roleName!=''">
      and role_name like concat('%',#{roleName},'%')
    </if>
  </where>
  order by create_time desc
</select>
3.4.1.3.业务层

新增

/**
 * 角色表服务类
 */
public interface RoleService {

    /**
     *  多条件查询角色表分页列表
     * @param roleDto 查询条件
     * @param pageNum 页码
     * @param pageSize 每页条数
     * @return Page<ResourceVo>
     */
    PageResponse<RoleVo> findRolePage(RoleDto roleDto, int pageNum, int pageSize);

}

实现类:

/**
 * 角色表服务实现类
 */
@Service
public class RoleServiceImpl implements RoleService {

    @Autowired
    RoleMapper roleMapper;

    @Override
    public PageResponse<RoleVo> findRolePage(RoleDto roleDto, int pageNum, int pageSize) {
        PageHelper.startPage(pageNum, pageSize);
        Page<List<Role>> page = roleMapper.selectPage(roleDto);
        PageResponse<RoleVo> pageResponse = PageResponse.of(page, RoleVo.class);
        return pageResponse;
    }

}
3.4.1.4.测试

打开前端页面,找到角色管理,能正常分页查询角色,即可

 3.4.2.角色添加

3.4.2.1.接口定义

在RoleController中定义新的方法

/**
 *  保存角色
 * @param roleDto 角色DTO对象
 * @return RoleVo
 */
@PutMapping
@ApiOperation(value = "角色添加",notes = "角色添加")
@ApiImplicitParam(name = "roleDto",value = "角色DTO对象",required = true,dataType = "roleDto")
@ApiOperationSupport(includeParameters = {"roleDto.roleName","roleDto.dataState"})
public ResponseResult<RoleVo> createRole(@RequestBody RoleDto roleDto) {
    roleService.createRole(roleDto);
    return ResponseResult.success();
}
3.4.2.2.mapper持久层
 int insert(Role record);

xml映射文件:

<insert id="insert" parameterType="com.zzyl.entity.Role">
    <selectKey keyProperty="id" order="AFTER" resultType="java.lang.Long">
      SELECT LAST_INSERT_ID()
    </selectKey>
    insert into sys_role (role_name, label, sort_no, 
      data_state, create_time, update_time, 
      remark, create_by, update_by, 
      data_scope)
    values (#{roleName,jdbcType=VARCHAR}, #{label,jdbcType=VARCHAR}, #{sortNo,jdbcType=INTEGER}, 
      #{dataState,jdbcType=CHAR}, #{createTime,jdbcType=TIMESTAMP}, #{updateTime,jdbcType=TIMESTAMP}, 
      #{remark,jdbcType=VARCHAR}, #{createBy,jdbcType=BIGINT}, #{updateBy,jdbcType=BIGINT}, 
      #{dataScope,jdbcType=CHAR})
  </insert>
3.4.2.3.业务层

在RoleService中新增方法,如下:

/**
 *  创建角色
 * @param roleDto 对象信息
 */
void createRole(RoleDto roleDto);

实现方法:

@Override
@Transactional
public void createRole(RoleDto roleDto) {
    //转换RoleVo为Role
    Role role = BeanUtil.toBean(roleDto, Role.class);
    roleMapper.insert(role);
}
3.4.2.4.测试

在前端页面中,能正常添加角色即可

3.4.3.根据角色查询选中的资源数据

3.4.3.1.接口定义

在RoleController中新增方法,如下:

@ApiOperation(value = "根据角色查询选中的资源数据")
@GetMapping("/find-checked-resources/{roleId}")
public ResponseResult<Set<String>> findCheckedResources(@PathVariable("roleId") Long roleId){
   Set<String> resources = roleService.findCheckedResources(roleId);
    return ResponseResult.success(resources);
}
3.4.3.2.mapper持久层

需要根据角色id到角色资源的中间表中查询对应的资源数据编号集合

在RoleResourceMapper中新增方法,如下:

@Select("select resource_no from sys_role_resource where role_id = #{roleId}")
Set<String> selectResourceNoByRoleId(Long roleId);
3.4.3.3.业务层

在RoleService中新增方法,如下:

/**
 * 根据角色id查询资源列表
 * @param roleId
 * @return
 */
Set<String> findCheckedResources(Long roleId);

实现类:

@Autowired
private RoleResourceMapper roleResourceMapper;

/**
 * 根据角色id查询资源列表
 *
 * @param roleId
 * @return
 */
@Override
public Set<String> findCheckedResources(Long roleId) {
    return roleResourceMapper.selectResourceNoByRoleId(roleId);
}
3.4.3.4.测试

当点击某个角色的时候,可以在右侧看到该角色已经关联的资源数据

3.4.4.角色修改

实现思路

3.4.4.1.接口定义

在RoleController中新增方法:

@PatchMapping
@ApiOperation(value = "角色修改",notes = "角色修改")
@ApiImplicitParam(name = "roleDto",value = "角色DTO对象",required = true,dataType = "roleDto")
@ApiOperationSupport(includeParameters = {"roleDto.roleName","roleDto.dataState","roleDto.dataScope","roleDto.checkedResourceNos","roleDto.checkedDeptNos","roleDto.id"})
public ResponseResult<Boolean> updateRole(@RequestBody RoleDto roleDto) {
    Boolean flag = roleService.updateRole(roleDto);
    return ResponseResult.success(flag);
}
3.4.4.2.mapper持久层

在RoleResourceMapper中新增删除方法

@Delete("delete from sys_role_resource where role_id = #{roleId}")
int deleteRoleResourceByRoleId(Long roleId);
3.4.4.3.业务层

在RoleService中新增方法:

/**
 *  修改角色表
 * @param roleDto 对象信息
 * @return Boolean
 */
Boolean updateRole(RoleDto roleDto);

实现方法:

@Override
public Boolean updateRole(RoleDto roleDto) {
    //转换RoleVo为Role
    Role role = BeanUtil.toBean(roleDto, Role.class);

    //TODO 该角色已分配用户,不能禁用

    //修改角色
    roleMapper.updateByPrimaryKeySelective(role);
    //判断是否修改角色对应的资源数据
    if (ObjectUtil.isEmpty(roleDto.getCheckedResourceNos())) {
        return true;
    }

    //删除原有角色资源中间信息
    roleResourceMapper.deleteRoleResourceByRoleId(role.getId());
    //保存角色资源中间信息
    List<RoleResource> roleResourceList = Lists.newArrayList();
    Arrays.asList(roleDto.getCheckedResourceNos()).forEach(n -> {
        RoleResource roleResource = RoleResource.builder()
                .roleId(role.getId())
                .resourceNo(n)
                .dataState(SuperConstant.DATA_STATE_0)
                .build();
        roleResourceList.add(roleResource);
    });
    //如果集合为空,则结束请求
    if (EmptyUtil.isNullOrEmpty(roleResourceList)) {
        return true;
    }
    //批量保存角色和资源的关系数据
    roleResourceMapper.batchInsert(roleResourceList);
    return true;
}
3.4.4.4.测试
  • 测试是否正常修改角色数据

  • 角色对应的资源是否可以重新设置

3.4.5.删除角色

3.4.5.1.接口定义 
/**
 * 删除角色
 */
@ApiOperation("删除角色")
@DeleteMapping("/{roleId}")
public ResponseResult remove(@PathVariable("roleId") Long roleId) {
    return ResponseResult.success(roleService.deleteRoleById(roleId));
}
3.4.5.2.mapper持久层

同上

3.4.5.3.业务层

在RoleService中新增方法,如下:

/**
 * 删除角色
 * @param roleId
 * @return
 */
int deleteRoleById(Long roleId);

实现类:

/**
 * 删除角色
 * @param roleId
 * @return
 */
@Override
public int deleteRoleById(Long roleId) {
    // 删除角色与菜单关联
    roleResourceMapper.deleteRoleResourceByRoleId(roleId);
    return roleMapper.deleteByPrimaryKey(roleId);
}

3.4.5.4.测试

测试是否能正常删除角色数据,是否能同时删除角色与资源的关系数据

4.需求开发-用户管理

用户管理在权限系统中起着重要的作用,可以帮助组织有效地管理和控制用户的身份验证、访问权限和个性化需求,提高系统的安全性和管理效率

4.1.需求分析

原型导航:

 4.2.表结构说明

以下权限系统中的核心5张表,重点查看用户表,以及与用户表所关联的表

 4.3.接口说明

  • 用户分页查询

  • 用户添加

  • 用户修改

  • 启用或禁用用户

  • 删除用户

  • 密码重置

  • 用户列表(部门管理中需要)

其他关联接口:

  • 在添加用户的时候,需要有一个下来选择对应的角色,所以需要先开发角色下拉框

  • 其他的关联接口,如部门树形结构选择岗位选择,目前的接口已提供,可以直接使用

 4.4.BCrypt密码加密

4.4.1.摘要加密

  • 摘要加密:对数据进行哈希运算来生成一个固定长度的摘要(也称为哈希值或消息摘要)。

  • 特点:不可逆,唯一性

  • 场景:密码加密、数字签名

  • 常见的加密算法:MD5、BCrypt

 4.4.2.MD5  VS  BCrypt加密

MD5BCrypt
长度是32长度是60
同一个字符串加密多次都是相同的同一个字符串加密多次是不同的

  4.4.3.代码测试

//md5加密
        String md5Pswd1 = DigestUtils.md5DigestAsHex("123456".getBytes());
//BCrypt加密
        String password1 = BCrypt.hashpw("123456", BCrypt.gensalt());
//BCrypt提供一个方法用于验证密码是否正确
        boolean checkpw = BCrypt.checkpw("123456", "$2a$10$rkB/70Cz5UvsE7F5zsBh8O2EYDoGus3/AnVrEgP5cTpsGLxM8iyG6");      
  • 返回值为true,表示密码匹配成功

  • 返回值为false,表示密码匹配失败

5.需求开发-后台登陆

5.1.需求分析

原型导航:

通过阅读原型,我们可以得知以下内容:

  • 用户输入用户名和密码可以进行登录请求,登录的过程中需要验证用户的信息

    • 账号是否存在(查用户后框架校验)

    • 是否被禁用(查用户后框架校验)

    • 密码是否正确(框架校验)

    • 如果当前用户没有菜单,也会提示未配置菜单权限(后面章节完成)

  • 勾选【自动登录】,后台用户再次进入系统时,无需输入账号密码,进入【工作台】页面(前端完成)

      自动登录有效期7天,失效后,再次进入系统时,进入【登录】页面

5.2.接口说明

 5.2.1.登录接口

接口地址:/security/login

请求方式:POST

请求示例:

{
  "username": "",
  "password": ""
}

响应示例:

{
    "code": 200,
    "msg": "操作成功",
    "data": {
        "id": "1671403256519078138",
        "createTime": "2023-06-19 23:11:17",
        "updateTime": "2023-07-12 16:26:01",
        "createBy": "1668522280851951617",
        "updateBy": "1671403256519078079",
        "remark": "",
        "username": "admin@qq.com",
        "password": "",
        "userType": "0",
        "nickName": "超级管理员",
        "email": "admin@qq.com",
        "realName": "超级管理员",
        "mobile": "15156409998",
        "sex": "0",
        "deptNo": "100001006000000",
        "postNo": "100001001000000",
        "userToken": "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW50VXNlciI6IntcInVzZXJuYW1lXCI6XCJhZG1pbkBxcS5jb21cIixcInBhc3N3b3JkXCI6XCJcIixcInVzZXJUeXBlXCI6XCIwXCIsXCJuaWNrTmFtZVwiOlwi6LaF57qn566h55CG5ZGYXCIsXCJlbWFpbFwiOlwiYWRtaW5AcXEuY29tXCIsXCJyZWFsTmFtZVwiOlwi6LaF57qn566h55CG5ZGYXCIsXCJtb2JpbGVcIjpcIjE1MTU2NDA5OTk4XCIsXCJzZXhcIjpcIjBcIixcInJlbWFya1wiOlwiXCIsXCJkZXB0Tm9cIjpcIjEwMDAwMTAwNjAwMDAwMFwiLFwicG9zdE5vXCI6XCIxMDAwMDEwMDEwMDAwMDBcIixcImlkXCI6MTY3MTQwMzI1NjUxOTA3ODEzOCxcImNyZWF0ZVRpbWVcIjoxNjg3MTg3NDc3MDAwLFwidXBkYXRlVGltZVwiOjE2ODkxNTAzNjEwMDAsXCJjcmVhdGVCeVwiOjE2Njg1MjIyODA4NTE5NTE2MTcsXCJ1cGRhdGVCeVwiOjE2NzE0MDMyNTY1MTkwNzgwNzl9IiwiZXhwIjoxNDY2MjQ2MzE0MX0.MVrrnF-sKkgjCGxr04KO_ZxJVZ5ZvFWlLD0uIQ7cPso"
    }
}

5.3.实现思路

根据我们的需求分析,最终确定项目认证的流程 

 5.4.功能实现

5.4.1.定义接口

在security中新增LoginController类来定义login方法,代码如下:

/**
 * @ClassName LoginController.java
 * @Description 登录接口
 */
@RestController
@Api(tags = "用户登录")
@RequestMapping("/security")
public class LoginController {

    @Autowired
    private LoginService loginService;

    @PostMapping("/login")
    @ApiOperation(value = "用户登录",notes = "用户登录")
    public ResponseResult<UserVo> login(@RequestBody LoginDto loginDto){
        UserVo userVo = loginService.login(loginDto);
        return ResponseResult.success(userVo);
    }
}

LoginDto

@Data
public class LoginDto {

    private String username;
    private String password;
}

5.4.2.Mapper层

在UserMapper中新增 findUserVoForLogin查询用户信息

@Select(" select * from sys_user where username = #{username}")
User selectByUsername(String username);

5.4.3.业务层

在security模块新增业务层代码:LoginService

/**
 * @author sjqn
 */
public interface LoginService {

    /**
     * 后台用户登录
     * @param loginDto
     * @return
     */
    UserVo login(LoginDto loginDto);
}

实现类LoginServiceImpl:

@Service
public class LoginServiceImpl implements LoginService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private JwtTokenManagerProperties jwtTokenManagerProperties;

    /**
     * 用户登录
     * @param loginDto
     * @return
     */
    @Override
    public UserVo login(LoginDto loginDto) {
        //根据用户名查询用户
        User user = userMapper.selectByUsername(loginDto.getUsername());
        //判断用户是否为空
        if(ObjectUtil.isEmpty(user)){
            throw new BaseException(BasicEnum.LOGIN_FAIL);
        }
        //判断用户是否被禁用
        if(user.getDataState().equals(SuperConstant.DATA_STATE_1)){
            throw new BaseException(BasicEnum.ACCOUNT_DISABLED);
        }
        //判断密码是否正确
        if(!BCrypt.checkpw(loginDto.getPassword(), user.getPassword())){
            throw new BaseException(BasicEnum.INCORRECT_PASSWORD);
        }

        //对象转换
        UserVo userVo = BeanUtil.toBean(user, UserVo.class);

        //密码脱敏
        userVo.setPassword("");

        //根据用户生成JWT
        Map<String,Object> claims = new HashMap<>();
        claims.put("currentUser", JSONUtil.toJsonStr(userVo));

        String token = JwtUtil.createJWT(jwtTokenManagerProperties.getBase64EncodedSecretKey(), jwtTokenManagerProperties.getTtl(),
                claims);
        userVo.setUserToken(token);

        return userVo;
    }
}

其中,由于不同用户的失败情况,需要提示不同的内容,所以我们需要在BasicEnum添加两天枚举

ACCOUNT_DISABLED(1420, "账号被禁用,请联系管理员"),
INCORRECT_PASSWORD(1421, "用户或密码错误"),

 5.4.4.测试

6.需求开发-项目鉴权

因为我们目前的项目是前后端分离的项目,我们可以通过用户分配的资源来确定,当前登录人是否有权限访问该资源

6.1.实现思路

以下实现思路需要结合登录认证一块实现,详细流程如下图:

  • 什么是白名单url?

    • 白名单url是指任意用户都可以访问的系统资源(接口路径)

  • 系统中哪些资源需要进行控制?

    • 在资源管理中,菜单中有定义按钮,而按钮对应的就是接口路径,如果用户分配了菜单,并且分配了按钮,则可以正常访问

    • 如果用户分配了菜单,但是没有分配按钮,则没有权限访问

    • 其中按钮的路径与白名单的路径是互斥的

 白名单准备

其中通用的资源列表可以在配置文件中进行配置,存储目录:zzyl-web\src\main\resources\config\application.yml

这里的配置文件也是叫做application.yml文件,不同的是目录是在config中

springboot启动之后会对两份application.yml文件的内容进行合并

config\application.yml内容如下:

zzyl:
  framework:
    security:
      public-access-urls:
        - PUT/bill/cancel/**
        - POST/role/init-roles
        - GET/room/getRoomsCheckedByFloorId/**
        - GET/roomTypes/status/**
        - POST/orders/**
        - PUT/bed/update
        - GET/contract/list
        - DELETE/post/**
        - PUT/contract
        - PUT/nursing/**
        - PUT/nursingTask/do
        - PATCH/dept/is_enable
        - POST/bill
        - DELETE/nursing_project/**
        - GET/roomTypes
        - POST/user/list
        - PUT/nursing/plan/**
        - PUT/nursingTask/cancel
        - DELETE/nursingLevel/delete/**
        - GET/room/getRoomsWithNurByFloorId/**
        - GET/visit/page
        - GET/iot/allProduct
        - GET/elder/selectList
        - PUT/nursingLevel/update
        - GET/visit
        - GET/floor/getAllWithRoomAndBed
        - GET/nursingTask/page
        - GET/bill/page
        - GET/message/pageQuery
        - PUT/reservation/**
        - DELETE/floor/delete/**
        - GET/swagger-resources
        - PUT/checkIn/reject
        - PUT/nursing_project
        - POST/checkIn
        - POST/dept/tree
        - PATCH/dept
        - PUT/checkIn
        - GET/elder/selectByIdCard
        - GET/roomTypes/typeName/**
        - GET/room/get/**
        - PUT/nursingLevel/**
        - PUT/user
        - PUT/checkIn/revocation
        - POST/checkIn/review
        - DELETE/message/allDelete
        - GET/nursingTask
        - GET/roomTypes/**
        - GET/bill/balance
        - PUT/alert-rule/update/**
        - GET/room/getRoomsWithDeviceByFloorId/**
        - GET/orders
        - POST/iot/QueryDeviceDetail
        - POST/checkIn/create
        - PUT/dept
        - PATCH/user
        - GET/alert-rule/read/**
        - POST/applications/selectByPage
        - PATCH/role
        - GET/user/current-user
        - POST/common/upload
        - DELETE/visit/**
        - DELETE/nursing/plan/**
        - GET/bill/**
        - PUT/checkIn/cancel
        - GET/nursing/plan
        - GET/iot/pageQueryDevice
        - POST/floor/add
        - GET/bill/arrears
        - GET/retreat_bills
        - GET/nursingLevel/listByPage
        - POST/iot/deviceOpenDoor/**
        - GET/bill/prepaidRechargeRecord/page
        - GET/iot/queryDeviceListByProductKey/**
        - POST/post/list
        - GET/message/queryVoiceNotifyStatus
        - PUT/alert-data/handleAlertData/**
        - GET/reservation
        - GET/alert-rule/get-page
        - DELETE/contract/**
        - GET/bed/read/**
        - DELETE/reservation/**
        - POST/post/page/**
        - PUT/post
        - POST/dept/list
        - PUT/retreat_bills/uploadRefundVoucher
        - POST/visit
        - POST/iot/UpdateDevice
        - POST/nursing/plan
        - PUT/checkIn/submit
        - POST/alert-rule/create
        - POST/orders/refund
        - POST/security/login
        - GET/bed/read/room/**
        - PUT/nursingTask/updateTime
        - DELETE/message/batchDelete
        - GET/alert-data/pageQuery
        - DELETE/bed/delete/**
        - DELETE/alert-rule/delete/**
        - POST/iot/QueryDevicePropertyStatus
        - PUT/room/update
        - PUT/visit/**
        - POST/reservation
        - PUT/message/updateVoiceNotifyStatus/**
        - GET/elder/selectListByPage
        - PUT/roomTypes/**
        - PUT/message/batchMarkRead
        - GET/orders/search
        - POST/orders/**
        - DELETE/iot/DeleteDevice
        - GET/visit/**
        - POST/checkIn/sign
        - POST/iot/QueryThingModelPublished
        - PUT/roomTypes/**
        - GET/trade-common-feign/query-refund
        - PUT/elder/setNursing
        - GET/checkIn
        - GET/floor/getAllFloorsWithDevice
        - PUT/nursing_project/**
        - GET/nursing_project/all
        - POST/nursingLevel/insertBatch
        - PUT/alert-rule/**
        - GET/iot/pageQueryDeviceServiceData
        - GET/floor/get/**
        - GET/message/countByReadStatus
        - DELETE/user/remove/**
        - DELETE/roomTypes/**
        - GET/nursing_project/**
        - GET/contract/**
        - PUT/reservation/**
        - POST/bed/create
        - POST/nursing_project
        - GET/reservation/**
        - GET/room/getRoomsByFloorId/**
        - GET/room/getAllVo
        - GET/nursing/plan/search
        - GET/nursingLevel/listAll
        - PUT/visit/**
        - POST/bill/prepaidRechargeRecord
        - GET/nursingLevel/**
        - POST/nursingLevel/insert
        - POST/user/page/**
        - GET/endpoints
        - GET/c-user/page
        - POST/pending_tasks/selectByPage
        - DELETE/room/delete/**
        - PATCH/post
        - GET/device-data/get-page
        - POST/iot/RegisterDevice
        - PUT/message/allMarkRead
        - PUT/role
        - PUT/user/is-enable/**
        - GET/floor/getAllFloorsWithNur
        - POST/role/page/**
        - POST/trade-common-feign/query-refund-record
        - GET/nursing_project
        - POST/iot/syncProductList
        - PUT/floor/update
        - GET/reservation/countByTime
        - DELETE/dept/**
        - PUT/reservation/**
        - POST/user/reset-passwords/**
        - POST/bill/payRecord
        - POST/roomTypes
        - GET/reservation/page
        - GET/floor/getAll
        - POST/room/add
        - DELETE/role/**
        - GET/nursing/plan/**
        - GET/resource/menus
        - POST/resource/list
        - PATCH/resource
        - POST/resource/enable
        - POST/resource/tree
        - GET/resource/myButten
        - PUT/resource
        - DELETE/resource/**

这里包含了大部分的接口径路,做测试的时候,可以适当进行删除

以上内容与资源列表中的按钮路径是互斥关系,这些url的访问是需要登录后操作,但是不需要有对应的资源权限

如果后期项目中新增了接口,根据不同的需求,需要配置在这个文件中或者是资源的按钮中

对于上面的这个配置,我们可以通过一个配置来进行读取

在common模块新增一个类SecurityConfigProperties,代码如下:

/**
 *  忽略配置
 */
@Slf4j
@Data
@ConfigurationProperties(prefix = "zzyl.framework.security")
@Configuration
public class SecurityConfigProperties {

    List<String> publicAccessUrls = new ArrayList<>();

}

6.2.功能实现

基于我们刚才分析的逻辑,我们有两部分的代码需要完成

  • 登录接口需要补全查询资源

 6.2.1.登录接口

需要修改登录的逻辑,添加两个内容,第一个是通过用户找到的资源列表,第二个是通用的资源列表

目前通用的资源列表已经准备好了,现在需要根据用户找到对应的资源列表

在ResourceMapper中新增方法,需要根据用户id查询对应的按钮资源,先回顾下RBAC的表关系

 通过用户id查询资源数据的sql如下:

select sr.request_path
from sys_user_role sur, sys_role_resource srr, sys_resource sr
where sur.role_id = srr.role_id
    and srr.resource_no = sr.resource_no
    and sr.data_state = '0'
    and sr.resource_type = 'r'
    and sur.user_id = 1671403256519078239

在ResourceMapper中新增方法,根据id查询资源数据

List<Resource>  selectListByUserId(Long userId);

映射文件:

<select id="selectListByUserId" resultType="com.zzyl.entity.Resource">
    select sr.request_path requestPath
    from sys_user_role sur,
         sys_role_resource srr,
         sys_resource sr
    where sur.role_id = srr.role_id
      and srr.resource_no = sr.resource_no
      and sr.data_state = '0'
      and sr.resource_type = 'r'
      and sur.user_id = #{userId}
</select>

实现类如下:

@Service
public class LoginServiceImpl implements LoginService {

    @Autowired
    private JwtTokenManagerProperties jwtTokenManagerProperties;

    @Autowired
    private ResourceMapper resourceMapper;

    @Autowired
    private SecurityConfigProperties securityConfigProperties;

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private UserMapper userMapper;

    /**
     * 用户登录
     * @param loginDto
     * @return
     */
    @Override
    public UserVo login(LoginDto loginDto) {
        //验证用户是否合法
        User user = userMapper.selectByUsername(loginDto.getUsername());
        //用户不存在或者用户状态未启用,则返回401,重新登录
        if(ObjectUtil.isEmpty(user) || user.getDataState().equals(SuperConstant.DATA_STATE_1)){
            throw new BaseException(BasicEnum.LOGIN_FAIL);
        }

        //检查密码是否正确
        if(!checkPassword(loginDto.getPassword(),user.getPassword())){
            throw new BaseException(BasicEnum.LOGIN_FAIL);
        }

        //对象转换
        UserVo userVo = BeanUtil.toBean(user, UserVo.class);

        //过滤敏感数据
        userVo.setPassword("");

        //获取当前用户对应的资源列表
        List<Resource> resourceList = resourceMapper.selectListByUserId(userVo.getId());
        //取出request_path
        List<String> urlList = resourceList.stream().map(Resource::getRequestPath).collect(Collectors.toList());

        //获取白名单url列表
        List<String> publicAccessUrls = securityConfigProperties.getPublicAccessUrls();
        urlList.addAll(publicAccessUrls);

        //生成token
        //把数据存入map
        Map<String,Object> claims = new HashMap<>();
        claims.put("currentUser", JSONUtil.toJsonStr(userVo));
        //创建token
        String token = JwtUtil.createJWT(jwtTokenManagerProperties.getBase64EncodedSecretKey()
                , jwtTokenManagerProperties.getTtl(), claims);
        //过期时间
        int ttl = jwtTokenManagerProperties.getTtl() / 1000;

        //存储到redis
        redisTemplate.opsForValue().set(CacheConstants.PUBLIC_ACCESS_URLS +userVo.getId(),JSONUtil.toJsonStr(urlList),
        ttl, TimeUnit.SECONDS);

        userVo.setUserToken(token);

        return userVo;
    }

    /**
     * 检查密码是否正确
     * @param password
     * @param dbPassword
     * @return
     */
    private boolean checkPassword(String password, String dbPassword) {
        return BCrypt.checkpw(password, dbPassword);
    }
}

在CacheConstants中定义新的常量

public static final String PUBLIC_ACCESS_URLS = "user:public_access_urls:";

6.2.2.自定义拦截器

基于我们刚才分析的逻辑,需要自定义授权管理器实现

在common模块下新增UserTokenInterceptor,代码如下:

@Component
public class UserTokenInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtTokenManagerProperties jwtTokenManagerProperties;

    @Autowired
    private RedisTemplate<String,String> redisTemplate;

    private AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        //判断当前请求是否是handler()
        if(!(handler instanceof HandlerMethod)){
            return true;
        }
        //获取token
        String token = request.getHeader(Constants.USER_TOKEN);
        //token是否为空
        if(StringUtils.isEmpty(token)){
            throw new BaseException(BasicEnum.LOGIN_LOSE_EFFICACY);
        }

        //解析token
        Claims claims = JwtUtil.parseJWT(jwtTokenManagerProperties.getBase64EncodedSecretKey(), token);
        if(ObjectUtil.isEmpty(claims)){
            throw new BaseException(BasicEnum.LOGIN_LOSE_EFFICACY);
        }

        //获取用户数据
        String userJson = String.valueOf(claims.get("currentUser"));

        //获取用户数据
        UserVo userVo = JSONUtil.toBean(userJson, UserVo.class);

        //从redis获取url列表
        String key = CacheConstants.PUBLIC_ACCESS_URLS+userVo.getId();
        String urlJson = redisTemplate.opsForValue().get(key);
        List<String> urlList = null;
        if(StringUtils.isNotEmpty(urlJson)){
            urlList = JSONUtil.toList(urlJson, String.class);
        }

        //获取当前请求路径
        String targetUrl = request.getMethod() + request.getRequestURI();

        //匹配当前路径是否在urllist集合中
        for (String url : urlList) {
            if(antPathMatcher.match(url,targetUrl)){
                //存储到当前线程中
                UserThreadLocal.setSubject(userJson);
                return true;
            }
        }

        throw new BaseException(BasicEnum.SECURITY_ACCESSDENIED_FAIL);

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserThreadLocal.removeSubject();
    }
}

AntPathMatcher:uri匹配规则

  1. ?匹配一个字符

  2. *匹配0个或多个字符

  3. **匹配0个或多个目录

6.2.3.注册自定义拦截器 

由于项目中有很多的请求都不需要使用安全框架进行过滤,如登录相关的接口、swagger、小程序端的接口

所以,我们需要在上述配置中添加放行的url列表,为了更方便的维护,我们可以放到application.yml文件中

如下:

zzyl:
  framework:
    security:
      ignore-url:
        - /resource/menus/**
        - /resource/myButten/**
        - /customer/**
        - /security/login
        - /security/logout
        - /doc.html
        - /*-swagger/**
        - /swagger-resources
        - /v2/api-docs
        - /webjars/**
        - /common/**
        - /ws/**

这些忽略的url不需要登录也不需要鉴权,不会走拦截器的逻辑

继续完善SecurityConfigProperties 配置文件,如下

/**
 *  忽略配置及跨域
 */
@Slf4j
@Data
@ConfigurationProperties(prefix = "zzyl.framework.security")
@Configuration
public class SecurityConfigProperties {

    /**
     * 默认密码
     */
    String defaulePassword ;

    List<String> publicAccessUrls = new ArrayList<>();

    List<String> ignoreUrl = new ArrayList<>();

}

找到WebMvcConfig类,注册上我们刚刚编写的这个新的拦截器

/**
 * webMvc高级配置
 */
@Configuration
@ComponentScan("springfox.documentation.swagger.web")
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private UserInterceptor userInterceptor;

    @Autowired
    private UserTokenInterceptor userTokenInterceptor;

    @Autowired
    private SecurityConfigProperties securityConfigProperties;

    //拦截的时候过滤掉swagger相关路径和登录相关接口
    private static final String[] EXCLUDE_PATH_PATTERNS = new String[]{"/swagger-ui.html",
            "/webjars/**",
            "/swagger-resources",
            "/v2/api-docs",
            // 登录接口
            "/customer/user/login"};

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //小程序的
        registry.addInterceptor(userInterceptor).excludePathPatterns(EXCLUDE_PATH_PATTERNS).addPathPatterns("/customer/**");
        //后台拦截
        String[] array = securityConfigProperties.getIgnoreUrl().toArray(new String[0]);

        registry.addInterceptor(userTokenInterceptor).excludePathPatterns(array).addPathPatterns("/**");
    }



    /**
     * 资源路径 映射
     */
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        //支持webjars
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
        //支持swagger
        registry.addResourceHandler("swagger-ui.html")
                .addResourceLocations("classpath:/META-INF/resources/");
        //支持小刀
        registry.addResourceHandler("doc.html")
                .addResourceLocations("classpath:/META-INF/resources/");
    }

    public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
    public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
    public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
        return builder -> {
            // 序列化
            builder.serializerByType(Long.class, ToStringSerializer.instance);
            builder.serializerByType(BigInteger.class, ToStringSerializer.instance);
            builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)));
            builder.serializerByType(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)));
            builder.serializerByType(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));

            // 反序列化
            builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)));
            builder.deserializerByType(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)));
            builder.deserializerByType(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));

        };
    }
}

6.3.测试

  • 可根据以下情况依次进行测试:

    • 正常登录后,访问的url在忽略列表或者在通用访问的url列表或者拥有按钮权限url后,可正常访问

    • 正常登录后,如果访问的url不在忽略列表或不在通用访问的url列表或没有按钮权限url后,报403错误

7.需求开发-动态菜单 

不同的用户登录之后,由于有不同的角色,对应的菜单也是不同的,最终的效果就是不同的登录人展示不同的菜单数据

7.1.接口说明

  • 接口地址:/resource/menus

  • 请求方式:GET

  • 请求参数:

前端无需传参,后端需要根据当前登录人获取用户的资源菜单数据

响应示例:

{
    "code": 200,
    "msg": "操作成功",
    "data": [
        {
            "name": "工作台",
            "path": "dashboard",
            "redirect": "/工作台",
            "children": [
                {
                    "name": "工作台",
                    "path": "/dashboard/base",
                    "redirect": "/工作台/工作台",
                    "meta": {
                        "title": "工作台",
                        "icon": "hlrw"
                    }
                }
            ],
            "meta": {
                "title": "工作台",
                "icon": "hlrw"
            }
        },
        {
            "name": "来访管理",
            "path": "appointment",
            "redirect": "/来访管理",
            "children": [
                {
                    "name": "预约来访",
                    "path": "/appointment/yylf",
                    "redirect": "/来访管理/预约来访",
                    "children": [
                        {
                            "name": "预约登记",
                            "path": "/appointment/subscribe",
                            "redirect": "/预约来访/预约登记",
                            "meta": {
                                "title": "预约登记",
                                "icon": "icon"
                            }
                        },
                        {
                            "name": "来访登记",
                            "path": "/appointment/comeVisit",
                            "redirect": "/预约来访/来访登记",
                            "meta": {
                                "title": "来访登记",
                                "icon": "icon"
                            }
                        }
                    ],
                    "meta": {
                        "title": "预约来访",
                        "icon": "cwgl-sei"
                    }
                }
            ],
            "meta": {
                "title": "来访管理",
                "icon": "icon"
            }
        }
    ]
}

7.2.实现思路

核心思想就是根据当前登录人,获取登录人的菜单数据,组装成接口要求的数据结构返回即可

7.3.功能实现 

7.3.1.定义接口

在ResourceController新增方法,定义左侧菜单接口

/**
 * @return
 *  左侧菜单
 */
@GetMapping("/menus")
@ApiOperation(value = "左侧菜单", notes = "左侧菜单")
public ResponseResult<List<MenuVo>> menus() {
     Long userId = UserThreadLocal.getMgtUserId();
    List<MenuVo> menus = resourceService.menus(userId);
    return ResponseResult.success(menus);
}

MenuVo

/**
 * 动态菜单VO对象
 */
@Data
@ApiModel("菜单VO")
public class MenuVo implements Serializable{

    // 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
    @ApiModelProperty(value = "路由名字")
    private  String name;

    @ApiModelProperty(value = "资源编号")
    private String resourceNo;

    @ApiModelProperty(value = "父资源编号")
    private String parentResourceNo;

    @ApiModelProperty(value = "请求路径")
    private String path;

    @ApiModelProperty(value = "层级名称展示")
    private String redirect;

    @ApiModelProperty(value = "子菜单")
    private List<MenuVo> children = new ArrayList<>();

    @ApiModelProperty(value = "meta属性")
    private MenuMetaVo meta;

}

MenuMetaVo

/**
 * 菜单meta属性
 */
@Data
@NoArgsConstructor
@ApiModel("菜单说明VO")
public class MenuMetaVo implements Serializable {

    @ApiModelProperty(value = "标题")
    private String title;

    @ApiModelProperty(value = "图标")
    private String icon;

    @ApiModelProperty(value = "角色")
    private List<String> roles;

    @Builder
    public MenuMetaVo(String title, String icon, List<String> roles) {
        this.title = title;
        this.icon = icon;
        this.roles = roles;
    }
}

7.3.2.mapper持久层

根据当前登录人的ID查询资源数据,需要关联5张表来获取数据

在ResourceMapper中定义新的方法,根据用户查询查询菜单数据

List<MenuVo> findListByUserId(Long userId);
  • 注意这个方法的返回值,我们直接返回了MenuVo集合对象(接口数据需要返回的数据)

 对应的映射文件:

<resultMap id="BaseResultVoMap" type="com.zzyl.vo.MenuVo">
  <result column="resource_no" jdbcType="VARCHAR" property="resourceNo"/>
  <result column="parent_resource_no" jdbcType="VARCHAR" property="parentResourceNo"/>
  <result column="resource_name" jdbcType="VARCHAR" property="name"/>
  <result column="request_path" jdbcType="VARCHAR" property="path"/>
</resultMap>

<select id="findListByUserId" resultMap="BaseResultVoMap">
   SELECT
    sr.resource_no,
    sr.parent_resource_no,
    sr.resource_name ,
    sr.request_path
  FROM
    sys_resource sr,
    sys_role_resource srr,
    sys_user_role sur
  WHERE
    sr.resource_no = srr.resource_no
    AND srr.role_id =sur.role_id 
    AND sr.data_state = '0'
    AND sr.resource_type = 'm'
    AND sur.user_id  = #{userId}
</select>
  • 注意新定义的BaseResultVoMap中,映射的字段要与表返回的字段和类中的属性呼应

  • 5张表关联查询,需要使用内连接查询,不满足条件的不进行展示,不能使用left join

  • 其中条件data_state = '0'表示只查询启用的资源菜单

  • 条件resource_type = 'm' 表示只查询菜单数据,不查询按钮数据

 7.3.3.业务层

在ResourceService中定义的新的方法,查询菜单数据

/**
 * 根据用户id查询对应的资源数据
 * @param userId
 * @return
 */
List<MenuVo> menus(Long userId);

实现方法:

/**
 * 根据用户id查询对应的资源数据
 * @param userId
 * @return
 */
@Override
public List<MenuVo> menus(Long userId) {
    //查询用户对应的所有的菜单数据
    List<MenuVo> menuVoList = resourceMapper.findListByUserId(userId);
    if (CollUtil.isEmpty(menuVoList)) {
        throw new BaseException(BasicEnum.USER_ROLE_AND_MENU_EMPTY);
    }
    //数据进行分组(parentNo:[{},{}])
    Map<String, List<MenuVo>> parentRNoMap = menuVoList
            .stream()
            .collect(Collectors.groupingBy(MenuVo::getParentResourceNo));
    //遍历所有数据
    menuVoList.forEach(menuVo -> {
        menuVo.setMeta(MenuMetaVo.builder().title(menuVo.getName()).build());
        menuVo.setRedirect("/"+menuVo.getName());
        //根据父编号到map中查找是否包含子菜单,如果包含则设置为当前菜单的子菜单
        List<MenuVo> menuVos = parentRNoMap.get(menuVo.getResourceNo());
        if(!EmptyUtil.isNullOrEmpty(menuVos)){
            menuVos.forEach(m->{
                m.setMeta(MenuMetaVo.builder().title(menuVo.getName()).build());
                m.setRedirect(m.getName());
            });
            menuVo.setChildren(menuVos);
        }
    });
    //根据根编号查找所有的子
    return parentRNoMap.get(SuperConstant.ROOT_PARENT_ID);
}

7.3.4.测试

  • 使用不同的用户进行登录,查看展示菜单的效果

  • 可以手动给不同的用户分配不同的角色,来测试完整流程

  • 31
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值