> 文章列表 > 【Java实战篇】Day11.在线教育网课平台--RBAC

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

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

文章目录

  • 一、用户授权
    • 1、RBAC
    • 2、资源服务授权流程
    • 3、授权相关的数据模型
    • 4、查询用户权限
    • 5、细粒度授权
  • 二、找回密码与注册
    • 1、找回密码
    • 2、注册
  • 三、需求:学生选课
    • 1、添加选课需求分析
    • 2、数据模型设计
    • 2、查询课程信息接口
    • 3、添加选课接口
    • 4、完善controller

一、用户授权

1、RBAC

RBAC有两种:

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

基于角色访问控制

判断当前访问者的身份,符合要求则放行,否则拒绝访问。
【Java实战篇】Day11.在线教育网课平台--RBAC
伪代码:

if(主体.hasRole("总经理角色id")){查询工资}

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

if(主体.hasRole("总经理角色id") || 主体.hasRole("部门经理角色id")){查询工资}

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

基于资源的访问控制

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

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

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

2、资源服务授权流程

  • 在资源服务集成Spring Security:在需要授权的接口处使用@PreAuthorize("hasAuthority('权限标识符')")进行控制
    【Java实战篇】Day11.在线教育网课平台--RBAC

  • 此时,用户请求该接口且无此权限,则抛出异常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、授权相关的数据模型

表结构和字段如下:

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

  • 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对象,对象中的权限在昨天的代码中是写死的:

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

定义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 collectionpermissions.add("p1");}//将用户权限放在XcUserExt中user.setPermissions(permissions);//为了安全在令牌中不放密码user.setPassword(null);//将user对象转jsonString 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();//机构idString 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<>();//机构idqueryWrapper.eq(CourseBase::getCompanyId,companyId);....

二、找回密码与注册

1、找回密码

【Java实战篇】Day11.在线教育网课平台--RBAC
请求参数:

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

执行逻辑:

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

2、注册

【Java实战篇】Day11.在线教育网课平台--RBAC
请求参数:

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

代码逻辑:

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

三、需求:学生选课

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

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

1、添加选课需求分析

UI设计:

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

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

  • 课程为免费课程时,用户可将其加入自己的课程表进行学习
    【Java实战篇】Day11.在线教育网课平台--RBAC

  • 课程为收费课程时,可选择支付或者试学
    【Java实战篇】Day11.在线教育网课平台--RBAC
    逻辑设计:

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

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

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

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

2、数据模型设计

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

  • 课程记录表
    【Java实战篇】Day11.在线教育网课平台--RBAC
# 字段说明选课类型: 免费课程、收费课程。
选课状态: 选课成功、待支付、选课删除。
对于免费课程: 课程价格为0,有效期默认365,开始服务时间为选课时间,结束服务时间为选课时间加1年后的时间,选课状态为选课成功。
对于收费课程: 按课程的现价、有效期确定开始服务时间、结束服务时间,选课状态为待支付。
收费课程的选课记录需要支付成功后选课状态为成功。
  • 我的课程表
    【Java实战篇】Day11.在线教育网课平台--RBAC
    选课订单id字段,是两张表的关联字段。时序图:

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

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> {@Overridepublic ContentServiceClient create(Throwable throwable) {return new ContentServiceClient() {@Overridepublic 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 {@AutowiredContentServiceClient contentServiceClient;@Testpublic 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 {@AutowiredXcChooseCourseMapper xcChooseCourseMapper;@AutowiredXcCourseTablesMapper xcCourseTablesMapper;@AutowiredContentServiceClient contentServiceClient;@AutowiredMyCourseTablesService myCourseTablesService;@AutowiredMyCourseTablesServiceImpl currentProxy;@Transactional@Overridepublic 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);//免费课程价格为0xcChooseCourse.setUserId(userId);xcChooseCourse.setCompanyId(coursepublish.getCompanyId());xcChooseCourse.setOrderType("700001");//免费课程xcChooseCourse.setCreateDate(LocalDateTime.now());xcChooseCourse.setStatus("701001");//选课成功xcChooseCourse.setValidDays(365);//免费课程默认365xcChooseCourse.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和同一个useridXcCourseTables 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);}