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