1. 学习路线图

2. 框架运行
若依框架官网
2.1 后端运行
0⃣️  数据库配置
配置文件位置:ruoyi-adimn/src/main/resources/application-druid.yml
配置步骤:
1⃣️  本地创建数据库ry-vue(通过配置文件得知)
2⃣️  运行sql脚本 (位置:sql/ry_20231130.sql)
✅ 运行完成后数据库表即可建立完成
3⃣️  查看redis配置 (位置:ruoyi-adimn/src/main/resources/application.yml)
本地启动redis
4⃣️  启动项目
启动类的位置:ruoyi-adimn/src/main/java/com/ruoyi/RuoYiApplication
2.2 前端运行
前端目录:RUOYI-UI
下载依赖:npm install
运行前端代码:npm run dev
3. 项目分析
RuoYi-Vue 是一个单体项目,但是根据多模块进行管理,将不同的模块进行隔离
其中, ruoyi-quartz和 ruoyi-quartz可以进行删除,其余模块对其没有任何依赖
3.1.文件结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
   | com.ruoyi      ├── common            // 工具类 │       └── annotation                    // 自定义注解 │       └── config                        // 全局配置 │       └── constant                      // 通用常量 │       └── core                          // 核心控制 │       └── enums                         // 通用枚举 │       └── exception                     // 通用异常 │       └── filter                        // 过滤器处理 │       └── utils                         // 通用类处理 ├── framework         // 框架核心 │       └── aspectj                       // 注解实现 │       └── config                        // 系统配置 │       └── datasource                    // 数据权限 │       └── interceptor                   // 拦截器 │       └── manager                       // 异步处理 │       └── security                      // 权限控制 │       └── web                           // 前端控制 ├── ruoyi-generator   // 代码生成(可移除) ├── ruoyi-quartz      // 定时任务(可移除) ├── ruoyi-system      // 系统代码 ├── ruoyi-admin       // 后台服务
   | 
 
PS:
1.代码分开存放:controller(位置:ruoyi-admin/src/main/java/com/ruoyi/web/controller/),controller调用的service、mapper、domain在ruoyi-system目录下
2.ruoyi-admin使用到framework内容
在ruoyi-admin的pom.xml中引入
同样在framework的pom.xml中引入ruoyi-system
4.RBAC模型
- 若依框架的权限管理功能是基于RBAC来实现的,即:系统中所有的权限,都是基于角色来控制的
 
- 框架对权限的控制,不仅支持菜单的功能,还支持菜单中的每一个按钮的权限控制
 
RBAC(基于角色的访问控制)模型包含的表有下面五张:
用户表 
 
角色表
 
菜单表
 
用户角色关联表
 
角色菜单关联表
 
查询当前登录用户的所拥有的权限菜单(登录人ID:2)
1 2 3 4
   | select * from sys_menu t1 left join sys_role_menu t2 on t1.menu_id = t2.menu_id left join sys_user_role t3 on t2.role_id = t3.role_id where t3.user_id = 2;
   | 
 
5.数据字典
数据字典的功能由两张表组成:
- sys_dict_type:字典类型表
 
- sys_dict_data:字典数量表
 
两者之间的关系:sys_dict_type表的dict_type字段关联sys_dict_data的dict_type字段
6.拦截器
6.1 前端拦截器
前端拦截器分为 前置拦截器 和 响应拦截器
前置拦截器
前置拦截器的代码写在request.js文件中,路径为 src - utils - request.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
   |  service.interceptors.request.use(config => {      const isToken = (config.headers || {}).isToken === false      const isRepeatSubmit = (config.headers || {}).repeatSubmit === false   if (getToken() && !isToken) {     config.headers['Authorization'] = 'Bearer ' + getToken()    }      if (config.method === 'get' && config.params) {     let url = config.url + '?' + tansParams(config.params);     url = url.slice(0, -1);     config.params = {};     config.url = url;   }   if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {     const requestObj = {       url: config.url,       data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,       time: new Date().getTime()     }     const requestSize = Object.keys(JSON.stringify(requestObj)).length;      const limitSize = 5 * 1024 * 1024;      if (requestSize >= limitSize) {       console.warn(`[${config.url}]: ` + '请求数据大小超出允许的5M限制,无法进行防重复提交验证。')       return config;     }     const sessionObj = cache.session.getJSON('sessionObj')     if (sessionObj === undefined || sessionObj === null || sessionObj === '') {       cache.session.setJSON('sessionObj', requestObj)     } else {       const s_url = sessionObj.url;                         const s_data = sessionObj.data;                       const s_time = sessionObj.time;                       const interval = 1000;                                if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) {         const message = '数据正在处理,请勿重复提交';         console.warn(`[${s_url}]: ` + message)         return Promise.reject(new Error(message))       } else {         cache.session.setJSON('sessionObj', requestObj)       }     }   }   return config }, error => {     console.log(error)     Promise.reject(error) })
 
  | 
 
前置拦截器完成功能:
- 在请求头中添加token
 
- get请求映射params参数
 
- 阻止重复请求重复提交(虽然前端做了,但一般后台也需要做借口幂等性校验)
 
6.2 响应拦截器
响应拦截器也在request.js文件中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
   |  service.interceptors.response.use(res => {          const code = res.data.code || 200;          const msg = errorCode[code] || res.data.msg || errorCode['default']          if (res.request.responseType ===  'blob' || res.request.responseType ===  'arraybuffer') {       return res.data     }     if (code === 401) {       if (!isRelogin.show) {         isRelogin.show = true;         MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {           isRelogin.show = false;           store.dispatch('LogOut').then(() => {             location.href = '/index';           })       }).catch(() => {         isRelogin.show = false;       });     }       return Promise.reject('无效的会话,或者会话已过期,请重新登录。')     } else if (code === 500) {       Message({ message: msg, type: 'error' })       return Promise.reject(new Error(msg))     } else if (code === 601) {       Message({ message: msg, type: 'warning' })       return Promise.reject('error')     } else if (code !== 200) {       Notification.error({ title: msg })       return Promise.reject('error')     } else {       return res.data     }   },error => {     console.log('err' + error)     let { message } = error;     if (message == "Network Error") {       message = "后端接口连接异常";     } else if (message.includes("timeout")) {       message = "系统接口请求超时";     } else if (message.includes("Request failed with status code")) {       message = "系统接口" + message.substr(message.length - 3) + "异常";     }     Message({ message: message, type: 'error', duration: 5 * 1000 })     return Promise.reject(error)   } )
 
  | 
 
6.2 后端拦截器
路径:ruoyi-framework/src/main/java/com/ruoyi/framework/interceptor/RepeatSubmitInterceptor.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
   | package com.ruoyi.framework.interceptor;
  import java.lang.reflect.Method; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import com.alibaba.fastjson2.JSON; import com.ruoyi.common.annotation.RepeatSubmit; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.utils.ServletUtils;
 
 
 
 
 
  @Component public abstract class RepeatSubmitInterceptor implements HandlerInterceptor {     @Override     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception     {         if (handler instanceof HandlerMethod)         {             HandlerMethod handlerMethod = (HandlerMethod) handler;             Method method = handlerMethod.getMethod();             RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);             if (annotation != null)             {                 if (this.isRepeatSubmit(request, annotation))                 {                     AjaxResult ajaxResult = AjaxResult.error(annotation.message());                     ServletUtils.renderString(response, JSON.toJSONString(ajaxResult));                     return false;                 }             }             return true;         }         else         {             return true;         }     }
      
 
 
 
 
 
 
      public abstract boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation); }
   | 
 
7.登录流程
登录流程图:
7.1 登录技术栈分析
上述流程中,登录成功后,最后会给前端返回一个token,会调用TokenService的createToken方法,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
   | 
 
 
 
 
 
 
  public String createToken(LoginUser loginUser) {     String token = IdUtils.fastUUID();     loginUser.setToken(token);     setUserAgent(loginUser);     refreshToken(loginUser);
      Map<String, Object> claims = new HashMap<>();     claims.put(Constants.LOGIN_USER_KEY, token);     return createToken(claims); }
  ---
 
 
 
 
 
 
  private String createToken(Map<String, Object> claims) {     String token = Jwts.builder()             .setClaims(claims)             .signWith(SignatureAlgorithm.HS512, secret).compact();     return token; }
 
 
  | 
 
实际上createToken方法就是采用JWT的方式生成令牌返回给前端
7.2 token校验
登录成功后,我们每一个请求到后台时,都需要对token进行权限校验,若依框架对token的校验是用过滤器实现的
过滤器位置:ruoyi-framework/src/main/java/com/ruoyi/framework/security/filter/JwtAuthenticationTokenFilter.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
   | package com.ruoyi.framework.security.filter;
  import java.io.IOException; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.framework.web.service.TokenService;
 
 
 
 
 
  @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {     @Autowired     private TokenService tokenService;
      @Override     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)             throws ServletException, IOException     {         LoginUser loginUser = tokenService.getLoginUser(request);         if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))         {             tokenService.verifyToken(loginUser);             UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());             authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));             SecurityContextHolder.getContext().setAuthentication(authenticationToken);         }         chain.doFilter(request, response);     } }
 
   | 
 
getLoginUser方法作用:
- 从请求头中获取到
token,然后解析得到我们设置进去的UUID 
- 然后再以
UUID作为key去Redis中获取到登录账号信息 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
   | 
 
 
 
 
  public LoginUser getLoginUser(HttpServletRequest request) {          String token = getToken(request);     if (StringUtils.isNotEmpty(token))     {         try         {             Claims claims = parseToken(token);                          String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);             String userKey = getTokenKey(uuid);             LoginUser user = redisCache.getCacheObject(userKey);             return user;         }         catch (Exception e)         {             log.error("获取用户信息异常'{}'", e.getMessage());         }     }     return null; }
 
  | 
 
verifyToken方法作用:校验令牌超时时间和当前时间的差值,如果小于20分钟的话,就会刷新令牌的超时时间
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
   | 
 
 
 
 
  public void verifyToken(LoginUser loginUser) {     long expireTime = loginUser.getExpireTime();     long currentTime = System.currentTimeMillis();     if (expireTime - currentTime <= MILLIS_MINUTE_TEN)     {         refreshToken(loginUser);     } }
 
  | 
 
8.按钮权限控制
若依框架不仅对菜单实现了权限控制,还对按钮实现了权限控制。
8.1 前端
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
   | <el-row :gutter="10" class="mb8">     <el-col :span="1.5">       <el-button         type="primary"         plain         icon="el-icon-plus"         size="mini"         @click="handleAdd"         v-hasPermi="['system:user:add']"       >新增</el-button>     </el-col>     <el-col :span="1.5">       <el-button         type="success"         plain         icon="el-icon-edit"         size="mini"         :disabled="single"         @click="handleUpdate"         v-hasPermi="['system:user:edit']"       >修改</el-button>     </el-col>      <el-col :span="1.5">       <el-button         type="danger"         plain         icon="el-icon-delete"         size="mini"         :disabled="multiple"         @click="handleDelete"         v-hasPermi="['system:user:remove']"       >删除</el-button>     </el-col>     <el-col :span="1.5">       <el-button         type="info"         plain         icon="el-icon-upload2"         size="mini"         @click="handleImport"         v-hasPermi="['system:user:import']"       >导入</el-button>     </el-col>     <el-col :span="1.5">       <el-button         type="warning"         plain         icon="el-icon-download"         size="mini"         @click="handleExport"         v-hasPermi="['system:user:export']"       >导出</el-button>     </el-col>     <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" :columns="columns"></right-toolbar>   </el-row>
   | 
 
很多按钮都有v-hasPermi="['xxx:xxx:xxx']",v-hasPermi是vue的自定义指令,属性值就是创建按钮时定义的权限标志。
其定义在ruoyi-ui/src/directive/permission/hasPermi.js文件中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
   |  
 
 
 
  import store from '@/store'
  export default {   inserted(el, binding, vnode) {     const { value } = binding     const all_permission = "*:*:*";     const permissions = store.getters && store.getters.permissions
      if (value && value instanceof Array && value.length > 0) {       const permissionFlag = value
        const hasPermissions = permissions.some(permission => {         return all_permission === permission || permissionFlag.includes(permission)       })
        if (!hasPermissions) {         el.parentNode && el.parentNode.removeChild(el)       }     } else {       throw new Error(`请设置操作权限标签值`)     }   } }
 
   | 
 
8.2 后端
8.2.1 接口权限
在controller中随便找一个接口为例:
1 2 3 4 5 6 7 8 9 10 11
   | 
 
  @PreAuthorize("@ss.hasPermi('system:menu:list')") @GetMapping("/list") public AjaxResult list(SysMenu menu) {     List<SysMenu> menus = menuService.selectMenuList(menu, getUserId());     return success(menus); }
 
 
  | 
 
发现其中有这样一行代码:
1
   | @PreAuthorize("@ss.hasPermi('system:menu:list')")
  | 
 
进入hasPermi方法,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
   | 
 
 
 
 
 
  public boolean hasPermi(String permission) {     if (StringUtils.isEmpty(permission))     {         return false;     }     LoginUser loginUser = SecurityUtils.getLoginUser();     if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))     {         return false;     }     PermissionContextHolder.setContext(permission);     return hasPermissions(loginUser.getPermissions(), permission); }
 
  | 
 
上面代码逻辑如下:
- 通过
SecurityUtils工具类从Security上下文中获取到登录用户信息 
- 然后从用户信息中获取到该用户所拥有的权限字符串集合
 
- 然后做对比,看其中是否包含【@ss.hasPermi(‘system:menu:list’)】中的权限字符串
 
- 包含就可以访问该方法,不包含就不可以访问该方法
 
权限方法
@PreAuthorize注解用于配置接口要求用户拥有某些权限才可访问,它拥有如下方法
| 方法 | 
参数 | 
描述 | 
| hasPermi | 
String | 
验证用户是否具备某权限 | 
| lacksPermi | 
String | 
验证用户是否不具备某权限,与 hasPermi逻辑相反 | 
| hasAnyPermi | 
String | 
验证用户是否具有以下任意一个权限 | 
| hasRole | 
String | 
判断用户是否拥有某个角色 | 
| lacksRole | 
String | 
验证用户是否不具备某角色,与 isRole逻辑相反 | 
| hasAnyRoles | 
String | 
验证用户是否具有以下任意一个角色,多个逗号分隔 | 
详细信息
8.2.1 数据权限
文档内容
9. 项目实战
项目背景和需求说明
学生成绩管理系统
项目表结构分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
   | DROP TABLE IF EXISTS `t_score`; CREATE TABLE `t_score` ( 	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', 	`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', 	`create_user_name` VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '创建人姓名', 	`course_id` BIGINT(2) NULL DEFAULT NULL COMMENT '课程ID', 	`user_id` BIGINT(1) NULL DEFAULT NULL COMMENT '用户ID', 	`score` int(11) NULL DEFAULT NULL COMMENT '分数', 	PRIMARY key(`id`) USING BTREE )ENGINE = INNODB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '分数表' ROW_FORMAT = Compact;
 
 
  DROP TABLE IF EXISTS `t_course`; CREATE TABLE `t_course` ( 	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', 	`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', 	`create_user_name` VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '创建人姓名', 	`course_name` VARCHAR(50) NULL DEFAULT NULL COMMENT '课程名称', 	`course_status` INT(1) NULL DEFAULT NULL COMMENT '课程状态(1:不可用;2:可用)', 	PRIMARY key(`id`) USING BTREE )ENGINE = INNODB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '课程表' ROW_FORMAT = Compact;
   | 
 
代码生成
选择 “生成代码” 按钮,即可下载生成好的代码。
拷贝代码到项目中并测试
① 后端代码 - 位置:main/java
分为四个文件夹controller、domain、mapper、service
domain实体类,拷贝放置位置ruoyi-system/src/main/java/com/ruoyi/system/domain
 
mapper,拷贝放置位置ruoyi-system/src/main/java/com/ruoyi/system/mapper
 
service,拷贝放置位置ruoyi-system/src/main/java/com/ruoyi/system/service
 
mapper.xml,拷贝放置位置ruoyi-system/src/main/resources/mapper/system
 
controller,拷贝放置位置ruoyi-admin/src/main/java/com/ruoyi/web/controller/system
 
② 前端代码 - 位置:vue
分为两个文件夹api、views
api下的js文件,拷贝放置位置RUOYI-UI/src/api/system 
views下的vue文件,拷贝放置位置RUOYI-UI/src/views/system,一般需要带着文件夹,例如hourdb/index.vue 
③ 执行sql文件,完成菜单生成
在ry-vue下执行即可,执行完成后,可在sys_menu下查看生成的菜单数据
④ 执行maven - compile