您现在的位置是:首页 >技术交流 >【Java实战篇】Day11.在线教育网课平台--RBAC网站首页技术交流

【Java实战篇】Day11.在线教育网课平台--RBAC

-代号9527 2023-06-06 12:00:02
简介【Java实战篇】Day11.在线教育网课平台--RBAC

一、用户授权

1、RBAC

RBAC有两种:

  • 基于角色的访问控制(Role-Based Access Control)
  • 基于资源的访问控制(Resource-Based Access Control)

基于角色访问控制

判断当前访问者的身份,符合要求则放行,否则拒绝访问。
在这里插入图片描述
伪代码:

if(主体.hasRole("总经理角色id")){

	查询工资
	
}

此时,如果需要修改角色的权限,就得修改代码:

if(主体.hasRole("总经理角色id") || 主体.hasRole("部门经理角色id")){

	查询工资
	
}

很明显,基于角色的访问控制,扩展性差

基于资源的访问控制

即按资源(权限)进行授权。判断主体是否有某个权限,而不判断主体是谁

在这里插入图片描述
伪代码:

if(主体.hasPermission("查询工资权限标识")){
    查询工资
}

2、资源服务授权流程

  • 在资源服务集成Spring Security:在需要授权的接口处使用@PreAuthorize("hasAuthority('权限标识符')")进行控制
    在这里插入图片描述

  • 此时,用户请求该接口且无此权限,则抛出异常org.springframework.security.access.AccessDeniedException: 不允许访问

  • 在统一异常处理器中捕捉处理一下

//要是直接捕捉AccessDeniedException,则需要在异常处理器所在的包引入Spring Security
//引入以后当前包就会被管控
//因此直接在Exception的捕捉处加一个IF分支来完成

@ResponseBody
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse exception(Exception e) {

   log.error("【系统异常】{}",e.getMessage(),e);
   e.printStackTrace();
   
   //!!!!!!!
   if(e.getMessage().equals("不允许访问")){
      return new RestErrorResponse("没有操作此功能的权限");
   }
   //!!!!!!
   return new RestErrorResponse(CommonError.UNKOWN_ERROR.getErrMessage());


}

以上的实现是通过解析令牌拿到当前操作用户的权限,拿这个权限和接口上注解的权限比对。

3、授权相关的数据模型

表结构和字段如下:

在这里插入图片描述

  • xc_user:用户表,存储所有用户的基本信息,姓名、邮箱…
  • xc_role:角色表,根据业务需求。角色的创建是为了方便给用户分配权限。(一个用户有多个角色,一个角色下也可以有多个用户,多对多,需要中间表,中间表存两个表的主键即可)
  • xc_user_role:用户角色表(中间表,用户和角色的关系表)
  • xc_menu:模块表,记录了菜单及菜单下的权限。权限,是对资源的访问控制。一个角色可拥有多个权限,一个权限可被多个角色所拥有。(角色与权限多对多,需要中间表)
  • xc_permission:角色权限表(中间表,角色和权限的关系表)

基于以上五张经典的权限控制表(三个单表+两个关系表),此时,查询用户拥有的权限可以:

# 根据用户id在用户角色关系表查角色id
# 根据角色id在角色权限表中查到权限id
# 根据权限id,查权限表
SELECT * FROM xc_menu WHERE id IN(
    SELECT menu_id FROM xc_permission WHERE role_id IN(
        SELECT role_id FROM xc_user_role WHERE user_id = '49'
    )
)

此时给用户分配(添加或者删除)权限:(创建角色就是为了方便分配权限,加权限就是加角色)

思路一:
- 不变用户角色,给角色本身加权限(update 角色权限关系表)

思路二:
- 给用户加角色,不变角色本身(update 用户角色关系表)

4、查询用户权限

框架判断用户权限通过解析jwt,jwt中的信息来自返回给框架的userDetail对象,对象中的权限在昨天的代码中是写死的:

在这里插入图片描述

定义Mapper接口,根据id查询权限:

public interface XcMenuMapper extends BaseMapper<XcMenu> {
    @Select("SELECT * FROM xc_menu WHERE id IN (SELECT menu_id FROM xc_permission WHERE role_id IN ( SELECT role_id FROM xc_user_role WHERE user_id = #{userId} ))")
    List<XcMenu> selectPermissionByUserId(@Param("userId") String userId);
}

修改UserServiceImpl类的getUserPrincipal方法,查询权限信息:

//查权限,XcUserExt包装成UserDetails对象

public UserDetails getUserPrincipal(XcUserExt user){

	//先拿个密码存下来,后面要置为null,密码要放在userDetail对象中,但user对象做为username去写jwt不能有密码
    String password = user.getPassword();
    //查询用户权限
    List<XcMenu> xcMenus = menuMapper.selectPermissionByUserId(user.getId());
    List<String> permissions = new ArrayList<>();
    if(xcMenus.size() > 0){
		 xcMenus.forEach(menu->{
            permissions.add(menu.getCode());
          });
        
    }else{
       //用户权限,如果不加则报Cannot pass a null GrantedAuthority collection
        permissions.add("p1");
        
    }
    //将用户权限放在XcUserExt中
    user.setPermissions(permissions);

    //为了安全在令牌中不放密码
    user.setPassword(null);
    //将user对象转json
    String userString = JSON.toJSONString(user);
    String[] authorities = permissions.toArray(new String[0]);
    UserDetails userDetails = User.withUsername(userString).password(password).authorities(authorities).build();
    return userDetails;

}

这一段的forEach很妙,创建一个需要的字段的List,通过一个forEach把po对象的List转成了我们只需要的字段的List,有种PO转VO而没建新类的味道:

...
List<String> permissions = new ArrayList<>();
    if(xcMenus.size() > 0){
		 xcMenus.forEach(menu->{
            permissions.add(menu.getCode());
          });
        
    }else{
.....

5、细粒度授权

细粒度授权也叫数据范围授权,即不同的用户所拥有的操作权限相同,但是能够操作的数据范围是不一样的。

一个例子:用户A和用户B都是教学机构,他们都拥有“我的课程”权限,但是两个用户所查询到的数据是不一样的。

细粒度授权涉及到不同的业务逻辑,通常在service层实现,根据不同的用户进行校验,根据不同的参数查询不同的数据或操作不同的数据。

@ApiOperation("课程查询接口")
@PreAuthorize("hasAuthority('xc_teachmanager_course_list')")//拥有课程列表查询的权限方可访问
@PostMapping("/course/list")
public PageResult<CourseBase> list(PageParams pageParams, @RequestBody QueryCourseParamsDto queryCourseParams){
    //取出用户身份Day9定义的工具类,从框架上下文拿当前用户
    XcUser user = SecurityUtil.getUser();
    //机构id
    String companyId = user.getCompanyId();
    return courseBaseInfoService.queryCourseBaseList(Long.parseLong(companyId),pageParams,queryCourseParams);
}

写Service层:

@Override
public PageResult<CourseBase> queryCourseBaseList(Long companyId,PageParams pageParams, QueryCourseParamsDto queryCourseParamsDto) {

 //构建查询条件对象
 LambdaQueryWrapper<CourseBase> queryWrapper = new LambdaQueryWrapper<>();
 //机构id
 queryWrapper.eq(CourseBase::getCompanyId,companyId);
 ....

二、找回密码与注册

1、找回密码

在这里插入图片描述
请求参数:

{
     cellphone: '',
     email: '',
     checkcodekey: '',
     checkcode: '',
     confirmpwd: '',
     password: ''
 }

执行逻辑:

  • 校验验证码,验证码不一致则抛异常
  • 校验两次密码是否一致,不一致则抛异常
  • 根据手机号或者邮箱查用户
  • 查到则update密码

2、注册

在这里插入图片描述
请求参数:

{
   cellphone: '',
   username: '',
   email: '',
   nickname: '',
   password: '',
   confirmpwd: '',
   checkcodekey: '',
   checkcode: ''
}

代码逻辑:

  • 校验验证码,如果不一致则抛出异常
  • 校验两次密码是否一致,如果不一致则抛出异常
  • 校验用户是否存在,如果存在则抛出异常
  • 向用户表、用户角色关系表添加数据。角色为学生角色

三、需求:学生选课

选课的整体流程是:学生选课、下单支付、开始学习,三个模块流程图如下:

在这里插入图片描述

1、添加选课需求分析

UI设计:

  • 在课程详情页点击马上学习

在这里插入图片描述

  • 课程为免费课程时,用户可将其加入自己的课程表进行学习
    在这里插入图片描述

  • 课程为收费课程时,可选择支付或者试学
    在这里插入图片描述
    逻辑设计:

  • 选课是将课程加入我的课程表的过程

  • 对免费课程选课后可直接加入我的课程表

  • 对收费课程选课后需要下单支付成功系统自动加入我的课程表

在这里插入图片描述

2、数据模型设计

我的课程表里的课,是能学习的。而对于收费课程,要确定支付成功后才能加入我的课程表。因此中间用选课记录表来过渡,该表中用status字段来标明是待支付、已支付

  • 课程记录表
    在这里插入图片描述
# 字段说明

选课类型: 免费课程、收费课程。
选课状态: 选课成功、待支付、选课删除。
对于免费课程: 课程价格为0,有效期默认365,开始服务时间为选课时间,结束服务时间为选课时间加1年后的时间,选课状态为选课成功。
对于收费课程: 按课程的现价、有效期确定开始服务时间、结束服务时间,选课状态为待支付。
收费课程的选课记录需要支付成功后选课状态为成功。

  • 我的课程表
    在这里插入图片描述
    选课订单id字段,是两张表的关联字段。时序图:

在这里插入图片描述

2、查询课程信息接口

学习中心服务需要远程调用内容管理服务,来查询课程信息。在课程发布controller中没找到这个功能的接口,在里面重新定义课程查询接口。该接口主要是给其他微服务调用,因此不用授权,这里用/r打头做个标记,以后加白名单/r/*就可以被放行

@ApiOperation("查询课程发布信息")
@ResponseBody
@GetMapping("/r/coursepublish/{courseId}")
public CoursePublish getCoursepublish(@PathVariable("courseId") Long courseId) {
    CoursePublish coursePublish = coursePublishService.getCoursePublish(courseId);
     return coursePublish;
}

新加Service接口及其实现:

CoursePublish getCoursePublish(Long courseId);
@Override
public CoursePublish getCoursePublish(Long courseId){
    CoursePublish coursePublish = coursePublishMapper.selectById(courseId);
    return coursePublish ;
}

在调用方(学习中心服务)定义Feign

package com.xuecheng.learning.feignclient;

import com.xuecheng.content.model.po.CoursePublish;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;

/**
 * @description 内容管理远程接口
 * value即服务名
 * fallbackFactory即降级处理的类
 * 注意只拿接口的定义就行,方法体不要
 */
@FeignClient(value = "content-api",fallbackFactory = ContentServiceClientFallbackFactory.class)
public interface ContentServiceClient {

    @ResponseBody
    @GetMapping("/content/r/coursepublish/{courseId}")
    public CoursePublish getCoursepublish(@PathVariable("courseId") Long courseId);

}

定义发生异常后的熔断处理类:

package com.xuecheng.learning.feignclient;

import com.xuecheng.content.model.po.CoursePublish;
import feign.hystrix.FallbackFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class ContentServiceClientFallbackFactory implements FallbackFactory<ContentServiceClient> {
    @Override
    public ContentServiceClient create(Throwable throwable) {
        return new ContentServiceClient() {

            @Override
            public CoursePublish getCoursepublish(Long courseId) {
                log.error("调用内容管理服务发生熔断:{}", throwable.toString(),throwable);
                return null;
            }
        };
    }
}

注意在feign远程调用你是会将字符串转LocalDataTime,需要在CoursePublish 类中LocalDateTime的属性上边添加如下代码:

@JsonFormat(shape = JsonFormat.Shape.STRING,pattern = "yyyy-MM-dd HH:mm:ss")

边写边测,看一下远程调用是否成功:

@SpringBootTest
public class FeignClientTest {

  @Autowired
 ContentServiceClient contentServiceClient;

  @Test
  public void testContentServiceClient(){
	   CoursePublish coursepublish = contentServiceClient.getCoursepublish(18L);
	   Assertions.assertNotNull(coursepublish);
  }
}

3、添加选课接口

请求参数为课程id。再定义Vo:

@Data
@ToString
public class XcChooseCourseVo extends XcChooseCourse {

    //学习资格,[{"code":"702001","desc":"正常学习"},{"code":"702002","desc":"没有选课或选课后没有支付"},{"code":"702003","desc":"已过期需要申请续期或重新支付"}]
    public String learnStatus;

}

接口定义:

@Slf4j
@RestController
public class MyCourseTablesController {


    @ApiOperation("添加选课")
    @PostMapping("/choosecourse/{courseId}")
    public XcChooseCourseVo addChooseCourse(@PathVariable("courseId") Long courseId)  {
        
    }

}

定义Service接口:

public interface MyCourseTablesService {

    /**
 * @description 添加选课
 * @param userId 用户id
 * @param courseId 课程id
*/
 public XcChooseCourseVo addChooseCourse(String userId, Long courseId);

}

写实现类:(先用注释写逻辑,再将注释中的一部分抽成单独的方法来调用

@Slf4j
@Service
public class MyCourseTablesServiceImpl implements MyCourseTablesService {

    @Autowired
    XcChooseCourseMapper xcChooseCourseMapper;

    @Autowired
    XcCourseTablesMapper xcCourseTablesMapper;

    @Autowired
    ContentServiceClient contentServiceClient;

    @Autowired
    MyCourseTablesService myCourseTablesService;
    
    @Autowired
    MyCourseTablesServiceImpl currentProxy;
	
	@Transactional
    @Override
    public XcChooseCourseVo addChooseCourse(String userId,Long courseId) {
        //查询课程信息
        CoursePublish coursepublish = contentServiceClient.getCoursepublish(courseId);
        
		//课程收费标准
        String charge = coursepublish.getCharge();
        //选课记录
        XcChooseCourse chooseCourse = null;
        if("201000".equals(charge)){//课程免费
            //添加免费课程(抽成方法)
            chooseCourse  = addFreeCoruse(userId, coursepublish);
            //添加到我的课程表(抽成方法)
            XcCourseTables xcCourseTables = addCourseTabls(chooseCourse);
        }else{
            //添加收费课程(抽成方法)
            chooseCourse  = addChargeCoruse(userId, coursepublish);
        }
        //获取学习资格
        ...
        return null;
    }


	//接下来写单独抽出来的方法
	//添加免费课程,免费课程加入选课记录表、我的课程表
	public XcChooseCourse addFreeCoruse(String userId, CoursePublish coursepublish) {
	    return null;
	}

	//添加收费课程
	public XcChooseCourse addChargeCoruse(String userId,CoursePublish coursepublish){
	
	    return null;
	}
	//添加到我的课程表
	public XcCourseTables addCourseTabls(XcChooseCourse xcChooseCourse){
	   return null;
	}
}


对上面单独抽出来的方法进行完善:

//添加免费课程,免费课程加入选课记录表、我的课程表
public XcChooseCourse addFreeCoruse(String userId, CoursePublish coursepublish) {
    //查询选课记录表是否存在免费的且选课成功的订单
    LambdaQueryWrapper<XcChooseCourse> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper = queryWrapper.eq(XcChooseCourse::getUserId, userId)
            .eq(XcChooseCourse::getCourseId, coursepublish.getId())
            .eq(XcChooseCourse::getOrderType, "700001")//免费课程
            .eq(XcChooseCourse::getStatus, "701001");//选课成功
    List<XcChooseCourse> xcChooseCourses = xcChooseCourseMapper.selectList(queryWrapper);
    if (xcChooseCourses != null && xcChooseCourses.size()>0) {
    	//查到则直接返回这条记录,不用再添加
        return xcChooseCourses.get(0);
    }
    //添加选课记录信息
    XcChooseCourse xcChooseCourse = new XcChooseCourse();
    xcChooseCourse.setCourseId(coursepublish.getId());
    xcChooseCourse.setCourseName(coursepublish.getName());
    xcChooseCourse.setCoursePrice(0f);//免费课程价格为0
    xcChooseCourse.setUserId(userId);
    xcChooseCourse.setCompanyId(coursepublish.getCompanyId());
    xcChooseCourse.setOrderType("700001");//免费课程
    xcChooseCourse.setCreateDate(LocalDateTime.now());
    xcChooseCourse.setStatus("701001");//选课成功

    xcChooseCourse.setValidDays(365);//免费课程默认365
    xcChooseCourse.setValidtimeStart(LocalDateTime.now());
    xcChooseCourse.setValidtimeEnd(LocalDateTime.now().plusDays(365));
    xcChooseCourseMapper.insert(xcChooseCourse);
    int result = xcChooseCourseMapper.insert(xcChooseCourse);
    if(result <= 0){
    	MyException.cast("添加信息到课程记录表失败!");
    }
    return xcChooseCourse;

}

注意上面的LocalDateTime.now().plusDays(365)。接下来完善添加收费课程到选课记录表:


//添加收费课程
public XcChooseCourse addChargeCoruse(String userId,CoursePublish coursepublish){

    //如果存在待支付交易记录直接返回
    LambdaQueryWrapper<XcChooseCourse> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper = queryWrapper.eq(XcChooseCourse::getUserId, userId)
            .eq(XcChooseCourse::getCourseId, coursepublish.getId())
            .eq(XcChooseCourse::getOrderType, "700002")//收费订单
            .eq(XcChooseCourse::getStatus, "701002");//待支付
    List<XcChooseCourse> xcChooseCourses = xcChooseCourseMapper.selectList(queryWrapper);
    if (xcChooseCourses != null && xcChooseCourses.size()>0) {
        return xcChooseCourses.get(0);
    }

    XcChooseCourse xcChooseCourse = new XcChooseCourse();
    xcChooseCourse.setCourseId(coursepublish.getId());
    xcChooseCourse.setCourseName(coursepublish.getName());
    xcChooseCourse.setCoursePrice(coursepublish.getPrice());
    xcChooseCourse.setUserId(userId);
    xcChooseCourse.setCompanyId(coursepublish.getCompanyId());
    xcChooseCourse.setOrderType("700002");//收费课程
    xcChooseCourse.setCreateDate(LocalDateTime.now());
    xcChooseCourse.setStatus("701002");//待支付

    xcChooseCourse.setValidDays(coursepublish.getValidDays());
    xcChooseCourse.setValidtimeStart(LocalDateTime.now());
    xcChooseCourse.setValidtimeEnd(LocalDateTime.now().plusDays(coursepublish.getValidDays()));
    xcChooseCourseMapper.insert(xcChooseCourse);
    return xcChooseCourse;
}

完善添加到课程表:

public XcCourseTables addCourseTabls(XcChooseCourse xcChooseCourse){
    //选课记录完成且未过期可以添加课程到课程表
    String status = xcChooseCourse.getStatus();
    if (!"701001".equals(status)){
       MyException.cast("选课未成功,无法添加到课程表");
    }
    //查询我的课程表,同一个课程id和同一个userid
    XcCourseTables xcCourseTables = getXcCourseTables(xcChooseCourse.getUserId(), xcChooseCourse.getCourseId());
    if(xcCourseTables!=null){
         return xcCourseTables;
    }
    XcCourseTables xcCourseTablesNew = new XcCourseTables();
    xcCourseTablesNew.setChooseCourseId(xcChooseCourse.getId());
    xcCourseTablesNew.setUserId(xcChooseCourse.getUserId());
    xcCourseTablesNew.setCourseId(xcChooseCourse.getCourseId());
    xcCourseTablesNew.setCompanyId(xcChooseCourse.getCompanyId());
    xcCourseTablesNew.setCourseName(xcChooseCourse.getCourseName());
    xcCourseTablesNew.setCreateDate(LocalDateTime.now());
    xcCourseTablesNew.setValidtimeStart(xcChooseCourse.getValidtimeStart());
    xcCourseTablesNew.setValidtimeEnd(xcChooseCourse.getValidtimeEnd());
    xcCourseTablesNew.setCourseType(xcChooseCourse.getOrderType());
    xcCourseTablesNew.setCourseType(xcChooseCourse.getOrderType());
    int result = xcCourseTablesMapper.insert(xcCourseTablesNew);
	if(result <= 0){
		MyException.cast("添加信息到课程表失败!"):
	}
    return xcCourseTablesNew;

}

/**
 * @description 根据课程和用户查询我的课程表中某一门课程
 * @param userId
 * @param courseId
*/
public XcCourseTables getXcCourseTables(String userId,Long courseId){
    XcCourseTables xcCourseTables = xcCourseTablesMapper.selectOne(new LambdaQueryWrapper<XcCourseTables>().eq(XcCourseTables::getUserId, userId).eq(XcCourseTables::getCourseId, courseId));
    return xcCourseTables;

}
    

完善获取学习资格的代码。定义获取学习资格的接口方法:

public interface MyCourseTablesService {

    public XcChooseCourseVo addChooseCourse(String userId, Long courseId);
    /**
     * @description 判断学习资格
     * @param userId
     * @param courseId
     * @return XcCourseTablesDto 学习资格状态 [{"code":"702001","desc":"正常学习"},{"code":"702002","desc":"没有选课或选课后没有支付"},{"code":"702003","desc":"已过期需要申请续期或重新支付"}]
     */
    public XcCourseTablesVo getLearningStatus(String userId, Long courseId);
}

写实现类:


/**
 * @description 判断学习资格
 * @param userId
 * @param courseId
 * @return XcCourseTablesVo 学习资格状态 [{"code":"702001","desc":"正常学习"},{"code":"702002","desc":"没有选课或选课后没有支付"},{"code":"702003","desc":"已过期需要申请续期或重新支付"}]
 * 呃这里就set一个code,没get到为啥单独定义个Vo,直接return String也行
*/
public XcCourseTablesVo getLearningStatus(String userId, Long courseId){
    //调用上面定义的方法,查询我的课程表
    XcCourseTables xcCourseTables = getXcCourseTables(userId, courseId);
    if(xcCourseTables==null){
        XcCourseTablesVo xcCourseTablesVo = new XcCourseTablesVo();
        //没有选课或选课后没有支付
        xcCourseTablesVo.setLearnStatus("702002");
        return xcCourseTablesVo;
    }
    XcCourseTablesVo xcCourseTablesVo = new XcCourseTablesVo();
    BeanUtils.copyProperties(xcCourseTables,xcCourseTablesVo);
    //是否过期,true过期,false未过期
    boolean isExpires = xcCourseTables.getValidtimeEnd().isBefore(LocalDateTime.now());
    if(!isExpires){
        //正常学习
        xcCourseTablesVo.setLearnStatus("702001");
       return xcCourseTablesVo;

    }else{
        //已过期
        xcCourseTablesVo.setLearnStatus("702003");
        return xcCourseTablesVo;
    }

}

.isBefore(LocalDateTime.now())来判断是否过期。各个方法都写完了,最后在service层中调用,并完善controller

4、完善controller

@Autowired
MyCourseTablesService courseTablesService;

@ApiOperation("添加选课")
@PostMapping("/choosecourse/{courseId}")
public XcChooseCourseVo addChooseCourse(@PathVariable("courseId") Long courseId) {
    //自定义工具类获取当前登录用户
    SecurityUtil.XcUser user = SecurityUtil.getUser();
    if(user == null){
       MyException.cast("请登录后继续选课");
    }
    String userId = user.getId();
    return  courseTablesService.addChooseCourse(userId, courseId);

}


风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。