您现在的位置是:首页 >技术交流 >【Java实战篇】Day11.在线教育网课平台--RBAC网站首页技术交流
【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);
}