📕开源风云系列
- 🍊本系列将从开源名将若依出发,探究优质开源项目脚手架汲取编程之道。
- 🍉从不分离版本开写到前后端分离版,再到微服务版本,乃至其中好玩的一系列增强Plus操作。
- 🍈希望你具备如下技术栈:
- 🍎Spring
- 🍍SpringMVC
- 🍐Mybatis/Mybatis-plus
- 🍅Thymeleaf
- 🥝SpringBoot
- 🍓Shiro
- 🍏SpringSecurity
- 🍌SpringCloud
- 🍒云服务器相关知识
- 本篇是分离版第四篇
- 文章同时同步到我的个人站点🍊欢迎来访!:
1、前端手册速过
1.1、开发规范
1.1.1、新增View
在@/views
文件下创建对应的文件夹,一般一个路由对应一个文件,该文件夹下有自己的utils
或components
组件供自己的文件夹使用。@/components
文件夹下的是公用组件,供@/views
下的文件使用。
1.1.2、新增样式
全局的@/style
放置一下全局公用的样式,每一个页面的样式就写在当前 views
下面,请记住加上scoped
就只会作用在当前组件内了,避免造成全局的样式污染。
1.2、交互流程
一个完整的前端 UI 交互到服务端处理流程是这样的:
前端点击按钮,按钮嗲用方法,通过封装的 request.js 发送请求,调用后端接口,获得返回的数据,前端重新渲染。
官文:
@/src/utils/request.js
是基于axios 的封装,便于统一处理 POST,GET 等请求参数,请求头,以及错误提示信息等。 它封装了全局request拦截器、response拦截器、统一的错误处理、统一做了超时处理、baseURL设置等。
1.3、页签缓存
在新建菜单的时候,会有个是否需要缓存的选项:
默认页签缓存,也就是当我们点开一个菜单的时候,假如我们不关闭它,而是去打开了其他页面,之后再打开第一个菜单页面,这个时候是不会重复再发送请求请求第一个页面的,而是会复用缓存。
这里作者想要给我们传达的知识点是:在编写路由 router 和路由对应的 view component 的时候一定要确保 两者的 name 是完全一致的
示例:
//router 路由声明
{
path: 'config',
component: ()=>import('@/views/system/config/index'),
name: 'Config',
meta: { title: '参数设置', icon: 'edit' }
}
//路由对应的view system/config/index
export default {
name: 'Config'
}
一定要保证两者的名字相同,切记写重或者写错。默认如果不写 name 就不会被缓存。
1.4、使用图标
若依的全局 Svg Icon 图标组件默认在 @/assets/icons/svg
注册到全局中,可以在项目中任意地方使用。
使用方式非常简单:
<!-- icon-class 为 icon 的名字; class-name 为 icon 自定义 class-->
<svg-icon icon-class="password" class-name='custom-class' />
如果我们要使用自己的图标怎么做呢?首先去 iconfont:iconfont
挑选想要的图标,下载svg
将其复制到@/assets/icons/svg
目录下,并且修改其宽高为128
然后按照上面的用法进行使用即可。
1.5、使用字典
字典管理是用来维护数据类型的数据,如下拉框、单选按钮、复选框、树选择的数据,方便系统管理员维护。
使用方式也很简单:
- 首先加载数据字典,可以是多个
export default {
dicts: ['字典类型']
}
- 翻译字典
<dict-tag :options="dict.type.字典类型" :value="scope.row.name"/>
效果如下
1.6、使用参数
参数设置是提供开发人员、实施人员的动态系统配置参数,不需要去频繁修改后台配置文件,也无需重启服务器即可生效。
this.getConfigKey("参数键名").then(response => {
this.xxxxx = response.msg;
});
1.7、应用路径
有些特殊情况需要部署到子路径下,例如:https://localhost/admin
,按照上述官方文档修改即可。
- 修改
vue.config.js
中的publicPath
属性
// 生产环境 /admin/,开发环境也是 /admin/
publicPath: process.env.NODE_ENV === "production" ? "/admin/" : "/admin/",
- 修改
router/index.js
,添加一行base
属性
export default new Router({
// 和上面的路径一致
base: "/admin",
mode: 'history', // 去掉url中的#
scrollBehavior: () => ({ y: 0 }),
routes: constantRoutes
})
/index
路由添加获取子路径/admin
修改layout/components/Navbar.vue
中的location.href
location.href = '/admin/index';
修改utils/request.js
中的location.href
location.href = '/admin/index';
最后访问:http://localhost:8080/admin
能正常访问和刷新表示成功
1.8、内容复制
也就是复制成功、失败调用的 methods 函数。
2、单元测试
单元测试也就是引入spring-boot-starter-test
依赖即可。
- 在
admin
下引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
- 在
src/test/java/com/kuang/
下创建单元测试类 - 编写测试代码,注意在类上加上
SpringBootTest
注解
@SpringBootTest(classes = RuoYiApplication.class)
对于无需Spring环境的测试,可以不用加上述注解。
注意:当用单元测试在Spring环境中测试的时候,如果去访问Controller,要保证该方法能够匿名访问,并且没有@PreAuthorize注解。并且,所访问代码中不能出现需要token才能执行的代码
所以单元测试的时候,去访问的Controller类上一定要有
@Anonymous
匿名注解才行。
3、外置Tomcat
我们之前在部署到云服务器的时候,使用的是内嵌Tomcat。但是呢,总有一些公司需要用外置Tomcat来跑项目,下面讲述如何用外置Tomcat跑后端。
- 在
admin
模块下引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
我们引入到admin模块中,并且设置作用域为provided。这样做的好处是可以让IDEA中自由运行我们的项目,但是在打包之后会自动排除tomcat(实际上是把tomcat放到lib-provided文件夹中,这样springboot可以用)。
- 修改
admin
模块的打包类型为 war 包,并且进行打包
这里可能会出现一些报错等,我这里是通过清理IDEA缓存,最终打包成功的。
- 下载 tomcat
- 找到我们打出来的war包,复制
- 打开
conf/tomcat-users.xml
,设置Tomcat的账号密码
- 在tomcat根目录下新建
webapps-javaee
文件夹,将war包丢进去,然后在bin/startup.bat
启动tomcat
- 访问
localhost:8080
输入账号密码
注意:如果发现 Tomcat 启动乱码,只需在
conf/logging.properties
中将 UTF-8 改为 GBK。
这里的小bug是,我们之前都是打war包直接丢到webapps下的,但是tomcat新版本会出404的bug,按照up主的方法丢到
webapps-javaee
下,我这里又出现了下面的bug,简单百度了下暂未发现好使的方法,先搁置了。总的外置Tomcat方法了解到就好。
4、禁止多终端同时登录
我们如果想让一个账号不允许两个人登录,那么涉及到一个问题。
第二个人要登录,怎么办?
1、不许登录,提示已经登录了。
2、询问是否强制直接T出先登录的那个人。
4.1、方式一:提示已经登录
这种方式比较简单,弹出一个提示即可。这种情况下,在用户每次登录认证成功的时候,去Redis查询一下有没有登录就行了。
在登录的时候会创建token,创建token的时候会刷新token,在 TokenService.java 中更改 refreshToken
方法,下面圈起来的是我们新增的代码
/**
* 刷新令牌有效期
*
* @param loginUser 登录信息
*/
public void refreshToken(LoginUser loginUser)
{
// 设置登录时间
loginUser.setLoginTime(System.currentTimeMillis());
// 设置登录过期时间
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
// kuang_判断当前用户是否已经登录
String onlineUser = redisCache.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + loginUser.getUsername());
if (onlineUser != null){
throw new ServiceException("用户已经登录,不允许再次登录");
}
//存入键为 userKey、值为 loginUser 入redis
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
// kuang_存入redis当前用户的登录信息的时候,再次存入哪个用户登录了
String nowLoginUser = CacheConstants.LOGIN_TOKEN_KEY + loginUser.getUsername();
// kuang_存入键为 nowLoginUser、值为 nowLoginUser 入redis
redisCache.setCacheObject(nowLoginUser, nowLoginUser, expireTime, TimeUnit.MINUTES);
}
-
在用户第一次登录的时候,onlineUser 肯定是空,所以不会走 if 语句,直接走下面的
redisCache.setCacheObject
将当前用户登录信息存进redis中,同时也将login_tokens: loginUser.getUsername()
存入 redis中,键是login_tokens: loginUser.getUsername()
,值也是login_tokens: loginUser.getUsername()
-
在用户第二次登录的时候,onlineUser 肯定非空,所以会走 if 语句前端报异常。
-
我们使用两个不同的浏览器依次登录admin账号,第二个浏览器登录则会提示!
第一次登录在 redis 中,可以看到存入的KV均为 login_tokens: loginUser.getUsername()
在系统登出的时候我们还要处理一下,因为登录的时候存入了KV为login_tokens: loginUser.getUsername()
,登出的时候也需要在 redis 中删除。如下图进行改造即可。
此方式的不好之处:后面登录的人只能等待前面的人30min不操作,或者前面那个人主动登出,不然后面登录的人别想登录上去。
这样我们可以看到,在登录admin时,redis中的值如下:
在登出的时候,redis中的值如下:
上述方式一也有一个bug,演示地址:解决在线用户bug
4.2、若依官方版本手册
这里我们可以使用若依官方给的代码:如何限制账户不允许多终端登录
application.yml
新增一个配置soloLogin
用于限制多终端同时登录。
# token配置
token:
# 是否允许账户多终端同时登录(true允许 false不允许)
soloLogin: false
Constants.java
新增一个常量LOGIN_USERID_KEY
公用
/**
* 登录用户编号 redis key
*/
public static final String LOGIN_USERID_KEY = "login_userid:";
- 调整
TokenService.java
,存储&刷新缓存用户编号信息
// 是否允许账户多终端同时登录(true允许 false不允许)
@Value("${token.soloLogin}")
private boolean soloLogin;
/**
* 删除用户身份信息
*/
public void delLoginUser(String token, Long userId)
{
if (StringUtils.isNotEmpty(token))
{
String userKey = getTokenKey(token);
redisCache.deleteObject(userKey);
}
if (!soloLogin && StringUtils.isNotNull(userId))
{
String userIdKey = getUserIdKey(userId);
redisCache.deleteObject(userIdKey);
}
}
/**
* 刷新令牌有效期
*
* @param loginUser 登录信息
*/
public void refreshToken(LoginUser loginUser)
{
// 设置登录时间
loginUser.setLoginTime(System.currentTimeMillis());
// 设置过期时间
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
if (!soloLogin)
{
// 缓存用户唯一标识,防止同一帐号,同时登录
String userIdKey = getUserIdKey(loginUser.getUser().getUserId());
// 将userIdKey 为键、userKey 为值存入 redis
redisCache.setCacheObject(userIdKey, userKey, expireTime, TimeUnit.MINUTES);
}
}
private String getUserIdKey(Long userId)
{
return Constants.LOGIN_USERID_KEY + userId;
}
- 自定义退出处理类
LogoutSuccessHandlerImpl.java
清除缓存方法添加用户编号
// 删除用户缓存记录
tokenService.delLoginUser(loginUser.getToken(), loginUser.getUser().getUserId());
- 登录方法
SysLoginService.java
,验证如果用户不允许多终端同时登录,清除缓存信息
// 是否允许账户多终端同时登录(true允许 false不允许)
@Value("${token.soloLogin}")
private boolean soloLogin;
/**
* 登录验证
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public String login(String username, String password, String code, String uuid)
{
// 验证码校验
validateCaptcha(username, code, uuid);
// 登录前置校验
loginPreCheck(username, password);
// 用户验证
Authentication authentication = null;
try
{
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
finally
{
AuthenticationContextHolder.clearContext();
}
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
recordLoginInfo(loginUser.getUserId());
// 增加代码
if (!soloLogin)
{
// 如果用户不允许多终端同时登录,清除缓存信息
String userIdKey = Constants.LOGIN_USERID_KEY + loginUser.getUser().getUserId();
String userKey = redisCache.getCacheObject(userIdKey);
if (StringUtils.isNotEmpty(userKey))
{
redisCache.deleteObject(userIdKey);
redisCache.deleteObject(userKey);
}
}
// 生成token
return tokenService.createToken(loginUser);
}
这样的话当被挤掉后,会提示如图的信息。
其实原理很简单:
第一次登录的时候在 redis 中存入 以login_userid:userId
为键,以login_tokens:userKey
为值的键值对。
第二次登录的时候,重新在 redis 中存入 以login_userid:userId
为键,以login_tokens:userKey
为值的键值对,将之前的覆盖,这样第一次登录的token就失效了。
4.2、方式二:询问是否T出
思路:前端点击强制登录,若用户已经登录,则后端移除先前登录的token,重新写入现在登录的token即可。先前token被移除后先前登录的那个人自动下线。
待更新
5、将项目部署至Tomcat
5.1、前端打包
这仅仅是部分人的需求,比如说小公司,程序没几个人用,就没必要用并发很高的Nginx,又为公司省了一波成本。
在开发的时候可以发现,请求的url中间会带上dev-api/
其实是因为我们的.env.development
设置了
同理在生产环境也请求了prod-api/
,我们打包静态资源就需要对生产环境进行修改
VUE_APP_BASE_API = '//localhost:8080/KuangStudy_Vue-admin'
VUE_APP_BASE_API 的修改是为了让前端成功找到后端接口,后端接口就在http://localhost:8080/KuangStudy_Vue-admin路径下。
- 好了,现在可以在
package.json
中进行打包了:
- 打包完成会生成 dist 文件夹
- 复制 dist 文件夹,将其拷贝到 tomcat 的
/webapps
路径下,因为我们之前设置了应用路径为/admin
,所以将dist
重命名为admin
- 在
admin
包下新建WEB-INF
文件夹,同时新建web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
version="3.1" metadata-complete="true">
<display-name>Router for Tomcat</display-name>
<error-page>
<error-code>404</error-code>
<location>/index.html</location>
</error-page>
</web-app>
5.2、后端打包
- 将
admin
模块的pom.xml中的打包方式由 jar 改为 war - 执行Maven的 pakcage 打包命令
- 将 war 包放入tomcat下的
webapps-javaee
目录下 - 启动Tomcat
不得不说,我自己实验还是不OK… 之后再折腾吧
6、集成jsencrypt实现密码加密传输方式
目前登录接口密码是明文传输,如果安全性有要求,可以调整成加密方式传输。参考如下:
- 进入在线生成公私钥对
- 修改前端
user.js
对密码进行rsa
加密
import { encrypt } from '@/utils/jsencrypt'
// 登录
Login({ commit }, userInfo) {
...
const password = encrypt(userInfo.password)
...
}
- 在
src/utils/jsencrypt.js
中将密钥对填写进去
- 后端工具类
sign
包下添加RsaUtils.java
,用于RSA
加密解密
package com.kuang.common.utils.sign;
import org.apache.commons.codec.binary.Base64;
import javax.crypto.Cipher;
import java.security.*;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
/**
* RSA加密解密
*
* @author ruoyi
**/
public class RsaUtils
{
// Rsa 私钥(从网站生成的私钥)
public static String privateKey = "MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDMd8sHb92UvkCg\n" +
"riOFzz6LfN/2GUgzJHEfixHAZRT2xiXrJ/uuQ/5xsKkei7LbgmAGzOU6XmiIB7sv\n" +
"EZQYo63C9LNnxE5i8+/lnCfa/yOtpv3I96vsZxjLw4O5yAF6xfPh8PLzS/6B7yuV\n" +
"8PkwaoWqrUQVBsj0BLCA+AwUAH++7jlB2bhYBMbrJnNTiLZi8V7V5YQGnRzge6if\n" +
"a6dR0CF8EvDrUPsz+9DOEEX3Kc4+Q8QFoUM9gm/+kTGkniABXa99TOuuVJQXjRjn\n" +
"od9fgrHY5rnMz/ItIhisQwtaN4bgeSAgRyiZcDF+dZNHX8O1/icVYaGRXAL/8+jO\n" +
"4Il+IlrFAgMBAAECggEAbvo6LYAvIVOeGlhKGY+h3+3YVWflgzStXbSK3wU+Oq7T\n" +
"1+3ssepfyW9ca6LhYjlCS46cgHDLhcHEfEirPDBi2KUeVxz6esTyGYsHKts+jztL\n" +
"FfAv1XEjyT0GWyjOfMfClojEJP/DVNzuqESRzBCVGk1O3abAHYoKDwYS8CxRKdyE\n" +
"nwdXFJexZXFvK2f4Ry/EJ8NsJKKFBCYSZEWddFMJyeV7AdLlXXV9PX7R3KYnhf5M\n" +
"tLb8+qxI7IMsWp6BtQV4euqJ+Aq1BFe3CfXLDkQfA42aMct2ftm5IN1MWvdtF4s9\n" +
"/VqrZ5Kdjt4Xosx6qGR03HnB5DOB21YE+qmemS7DJQKBgQDs5YQnC+IpGMw+hynH\n" +
"olKj3H7CyEbUCXnr8LpmXadZtT8SFUhwSpTszudsKX3JZkT39PsCThEw1Od1kegL\n" +
"gu5XWAzqv8qBREAPopV7/ZOIV4ZpSgvmigMS8Avw7+m1rQByLkYA3MS/jmlOR/ZT\n" +
"7RQ0byr5+DpM8I++Sf+wXcHyrwKBgQDc9NJnXJiaJKKsonZh31uJs1t02SibxZb3\n" +
"r+TsC6OffTHIfNJVm++RkFd4Nd5AedjZpTsov8Lct9nYwuYfwNFPzwncxUDSxdoV\n" +
"wjXe8fxPDcaOGyIe84tvrvDunPi2t37i9AWnrmfFnL1Lawh4St3y5tKUWqGT3nBk\n" +
"LO2e57o2ywKBgBRK/O0TWYZHt4dLsMqHHykmRD2PRnH3ddg+QNlwAFvw6Lw10oS7\n" +
"/tHiT3S2gS31cscC11W1NkfGlSU/IG+MAbq9si2RzFkSbaJpPQDoEfFF/h+a1jYj\n" +
"cGv44Cz9gDmt3jHpwNlmD/yQyHiKNZGu52Iue7H6D5RzrvyP/jUvcuPFAoGAaBSu\n" +
"899rRtjRBA73Qr0q6l9hSlZsyYu2xG/HnuUQqzUW+UDK92af3+qlOUvuqGJ9InCx\n" +
"wE0sJjo4VOHh8r0qG7UECUmlcmOeijdUlvNYDYkIqbHgTcIdQXufpFau1ar0C0Y1\n" +
"T7pYyX4+sML3V2q0yBGwyg8zI2tZd+at+/deAScCgYA7Xtf9lfuuR8H65eg57oP3\n" +
"DztVa64MUrgdS69tai2yxkZUeWNqSDavds7wLYCwsUkBlGIZ8iOjt+2J6yXRNJVG\n" +
"gp+C0RmyoARhQf0hueWPfobDMtgBdO6DgvqrxDte/SqxhTSllsjxojZNqbixMT50\n" +
"O+DvDItmU0LW39BuLrjXDg==";
/**
* 私钥解密
*
* @param privateKeyString 私钥
* @param text 待解密的文本
* @return 解密后的文本
*/
public static String decryptByPrivateKey(String text) throws Exception
{
return decryptByPrivateKey(privateKey, text);
}
/**
* 公钥解密
*
* @param publicKeyString 公钥
* @param text 待解密的信息
* @return 解密后的文本
*/
public static String decryptByPublicKey(String publicKeyString, String text) throws Exception
{
X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(Base64.decodeBase64(publicKeyString));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(x509EncodedKeySpec);
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, publicKey);
byte[] result = cipher.doFinal(Base64.decodeBase64(text));
return new String(result);
}
/**
* 私钥加密
*
* @param privateKeyString 私钥
* @param text 待加密的信息
* @return 加密后的文本
*/
public static String encryptByPrivateKey(String privateKeyString, String text) throws Exception
{
PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(Base64.decodeBase64(privateKeyString));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(pkcs8EncodedKeySpec);
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
byte[] result = cipher.doFinal(text.getBytes());
return Base64.encodeBase64String(result);
}
/**
* 私钥解密
*
* @param privateKeyString 私钥
* @param text 待解密的文本
* @return 解密后的文本
*/
public static String decryptByPrivateKey(String privateKeyString, String text) throws Exception
{
PKCS8EncodedKeySpec pkcs8EncodedKeySpec5 = new PKCS8EncodedKeySpec(Base64.decodeBase64(privateKeyString));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(pkcs8EncodedKeySpec5);
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] result = cipher.doFinal(Base64.decodeBase64(text));
return new String(result);
}
/**
* 公钥加密
*
* @param publicKeyString 公钥
* @param text 待加密的文本
* @return 加密后的文本
*/
public static String encryptByPublicKey(String publicKeyString, String text) throws Exception
{
X509EncodedKeySpec x509EncodedKeySpec2 = new X509EncodedKeySpec(Base64.decodeBase64(publicKeyString));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(x509EncodedKeySpec2);
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] result = cipher.doFinal(text.getBytes());
return Base64.encodeBase64String(result);
}
/**
* 构建RSA密钥对
*
* @return 生成后的公私钥信息
*/
public static RsaKeyPair generateKeyPair() throws NoSuchAlgorithmException
{
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(1024);
KeyPair keyPair = keyPairGenerator.generateKeyPair();
RSAPublicKey rsaPublicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) keyPair.getPrivate();
String publicKeyString = Base64.encodeBase64String(rsaPublicKey.getEncoded());
String privateKeyString = Base64.encodeBase64String(rsaPrivateKey.getEncoded());
return new RsaKeyPair(publicKeyString, privateKeyString);
}
/**
* RSA密钥对对象
*/
public static class RsaKeyPair
{
private final String publicKey;
private final String privateKey;
public RsaKeyPair(String publicKey, String privateKey)
{
this.publicKey = publicKey;
this.privateKey = privateKey;
}
public String getPublicKey()
{
return publicKey;
}
public String getPrivateKey()
{
return privateKey;
}
}
}
- 登录方法
SysLoginController.java
,对密码进行rsa
解密
String token = loginService.login(loginBody.getUsername(), RsaUtils.decryptByPrivateKey(loginBody.getPassword()), loginBody.getCode(), loginBody.getUuid());
- 测试
[!note]
若依的公钥和私钥是固定的,其实是不安全的,这里有随机的公钥和私钥方法:若依(RuoYi)SpringBoot框架密码加密传输(前后分离板)
7、限流控制
我们需要预防某些用户疯狂点击占用服务器资源,需要限流。ruoyi在新版中,加入了限流控制,类为RateLimiterAspect.java
达能我们需要限流的时候,需要加@RatyeLimiter
注解,假如我们给获取用户列表的方法加上限流注解

user.password.retry.limit.exceed=user password retry {0} time(s), your account is locked 10 minutes!
user.password.delete=sorry, your account has been deleted!
user.blocked=blocked
role.blocked=blocked
user.logout.success=logout success
length.not.valid=length is from {min} to {max}!
user.username.not.valid=* from 2 to 20, It is composed of Chinese characters, letters, numbers or underscores and must start with a non number
user.password.not.valid=* 5-50 characters
user.email.not.valid=email format error
user.mobile.phone.number.not.valid=Mobile number format error
user.login.success=login success
user.register.success=login was successful
user.notfound=Please login again
user.forcelogout=The administrator forcibly logs out, please log in again
user.unknown.error=Unknown error, please login again
##文件上传消息
upload.exceed.maxSize=The size of the uploaded file exceeds the limit< Br/>The maximum file size allowed is:{0}MB!
upload.filename.exceed.length=The maximum length of the uploaded file name is {0} characters
##权限
no.permission=You do not have data permission, please contact the administrator to add permission [{0}]
no.create.permission=You do not have permission to create data. Please contact the administrator to add permission [{0}]
no.update.permission=You do not have permission to modify data. Please contact the administrator to add permission [{0}]
no.delete.permission=You do not have permission to delete data. Please contact the administrator to add permission [{0}]
no.export.permission=You do not have permission to export data. Please contact the administrator to add permission [{0}]
no.view.permission=You do not have permission to view data. Please contact the administrator to add permission [{0}]
- 再新建
messages_zh_CN.properties
,中文,注意是在 i18n 右键
#错误消息
not.null=* 必须填写
user.jcaptcha.error=验证码错误
user.jcaptcha.expire=验证码已失效
user.not.exists=用户不存在/密码错误
user.password.not.match=用户不存在/密码错误
user.password.retry.limit.count=密码输入错误{0}次
user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定10分钟
user.password.delete=对不起,您的账号已被删除
user.blocked=用户已封禁,请联系管理员
role.blocked=角色已封禁,请联系管理员
user.logout.success=退出成功
length.not.valid=长度必须在{min}到{max}个字符之间
user.username.not.valid=* 2到20个汉字、字母、数字或下划线组成,且必须以非数字开头
user.password.not.valid=* 5-50个字符
user.email.not.valid=邮箱格式错误
user.mobile.phone.number.not.valid=手机号格式错误
user.login.success=登录成功
user.register.success=注册成功
user.notfound=请重新登录
user.forcelogout=管理员强制退出,请重新登录
user.unknown.error=未知错误,请重新登录
##文件上传消息
upload.exceed.maxSize=上传的文件大小超出限制的文件大小!<br/>允许的文件最大大小是:{0}MB!
upload.filename.exceed.length=上传的文件名最长{0}个字符
##权限
no.permission=您没有数据的权限,请联系管理员添加权限 [{0}]
no.create.permission=您没有创建数据的权限,请联系管理员添加权限 [{0}]
no.update.permission=您没有修改数据的权限,请联系管理员添加权限 [{0}]
no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}]
no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}]
no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}]
-
默认文件messages.properties保持不变
-
在
ResourcesConfig.java
类中加入如下代码
/**
* 自定义拦截规则
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
registry.addInterceptor(localeChangeInterceptor());
}
/**
* 国际化
*/
@Bean("KuagLocaleChangeInterceptor")
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor lci = new LocaleChangeInterceptor();
lci.setParamName("lang");
return lci;
}
@Bean("KuangLocaleResolver")
public LocaleResolver localeResolver()
{
CookieLocaleResolver cookieLocaleResolver = new CookieLocaleResolver();
// 默认语言
cookieLocaleResolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
return cookieLocaleResolver;
}
- CookieLocaleResolver是为了从cookie中拿到当前的地区,以便获得当前的语言,默认是简体中文。如果没有设置默认值,那么会从请求头的Accept-Language中获得语言偏好
- LocaleChangeInterceptor是为了获得请求参数中的lang参数来确定地区,方便前端人员通过按钮来切换语言。
- 这里我给两个拦截器都起了别名,因为不起别名会提示Bean出现同名的情况
-
在前端request.js中的请求拦截器来携带lang参数
从 cookie 中获取lang参数,默认 lang 是 zh_CN 中文,
import Cookies from "js-cookie";
// 从 Cookie 中获取lang参数
let lang = Cookies.get("lang");
if (lang == null || lang === '') {
lang = "zh_CN";
}
if (config.params == null) {
config.params = {}
}
config.params.lang = lang
- 在login.vue页面上来添加一个按钮来设置名为lang的cookie
<!--国际化-->
<div style="right: 0;top: 0;position: absolute;">
<el-switch
v-model="lang"
active-color="#13ce66"
inactive-color="#ff4949">
</el-switch>
</div>
data:
data() {
return {
...
lang: true,
}
}
watch:
watch: {
//国际化
lang: {
handler: function (newValue, oldValue) {
console.log(newValue, oldValue)
if (newValue === true) {
Cookies.set("lang", "zh_CN")
} else {
Cookies.set("lang", "en_US")
}
},
},
.....
}
created:
created() {
...
this.getLang();
},
methods:
methods: {
// 国际化
getLang() {
let lang = Cookies.get("lang")
if (lang == null || lang === '') {
Cookies.set("lang", "zh_CN")
this.lang = true //中文
}
if (lang === 'zh_CN') {
this.lang = true;
} else if (lang === 'en_US') {
this.lang = false;
}
},
}
重启项目,前端登录页面多一个按钮,通过按钮的切换,想中文就中文,想英文就英文。就像下面这样:
8.2、前端国际化
- 安装依赖
# 因为用的Vue2,所以不能用9版本,Vue3用9版本
npm install vue-i18n@8
- src 目录下创建 lang 目录,存放国际化文件。此处包含三个文件,分别是 index.js 、 zh.js 、 en.js
index.js
// index.js
import Vue from 'vue'
import VueI18n from 'vue-i18n'
import Cookies from 'js-cookie'
import elementEnLocale from 'element-ui/lib/locale/lang/en' // element-ui lang
import elementZhLocale from 'element-ui/lib/locale/lang/zh-CN'// element-ui lang
import enLocale from './en'
import zhLocale from './zh'
Vue.use(VueI18n)
const messages = {
en_US: {
...enLocale,
...elementEnLocale
},
zh_CN: {
...zhLocale,
...elementZhLocale
}
}
const i18n = new VueI18n({
// 设置语言 选项 en | zh
locale: Cookies.get('lang') || 'zh_CN',
// 设置文本内容
messages
})
export default i18n
zh.js
// zh.js
export default {
login: {
rememberMe:"记住密码",
title: '若依后台管理系统',
logIn: '登录',
username: '账号',
password: '密码'
},
tagsView: {
refresh: '刷新',
close: '关闭',
closeOthers: '关闭其它',
closeAll: '关闭所有'
},
settings: {
title: '系统布局配置',
theme: '主题色',
tagsView: '开启 Tags-View',
fixedHeader: '固定 Header',
sidebarLogo: '侧边栏 Logo'
}
}
en.js
// en.js
export default {
login: {
rememberMe:"Remember Me",
title: 'RuoYi Login Form',
logIn: 'Log in',
username: 'Username',
password: 'Password'
},
tagsView: {
refresh: 'Refresh',
close: 'Close',
closeOthers: 'Close Others',
closeAll: 'Close All'
},
settings: {
title: 'Page style setting',
theme: 'Theme Color',
tagsView: 'Open Tags-View',
fixedHeader: 'Fixed Header',
sidebarLogo: 'Sidebar Logo'
}
}
- 修改 main.js
import i18n from './lang'
// use添加i18n
Vue.use(Element, {
size: Cookies.get('size') || 'medium', // set element-ui default size
i18n: (key, value) => i18n.t(key, value)
})
new Vue({
el: '#app',
router,
store,
i18n,
render: h => h(App)
})
- 在 src/store/getters.js 中添加 language
language: state => state.app.language,
- 在 src/store/modules/app.js 中增量添加 i18n
const state = {
sidebar: {
opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true,
withoutAnimation: false,
hide: false
},
device: 'desktop',
size: Cookies.get('size') || 'medium',
language: Cookies.get('lang') || 'zh_CN'
}
mutations:
SET_LANGUAGE: (state, language) => {
state.language = language
Cookies.set('lang', language)
},
actions:
setLanguage({ commit }, language) {
commit('SET_LANGUAGE', language)
},
- 在 src/components/LangSelect/index.vue 中创建国际化组件
<template>
<el-dropdown trigger="click" class="international" @command="handleSetLanguage">
<div>
<svg-icon class-name="international-icon" icon-class="language" />
</div>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :disabled="language==='zh_CN'" command="zh_CN">
中文
</el-dropdown-item>
<el-dropdown-item :disabled="language==='en_US'" command="en_US">
English
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<script>
export default {
computed: {
language() {
return this.$store.getters.language
}
},
methods: {
handleSetLanguage(lang) {
this.$i18n.locale = lang
this.$store.dispatch('app/setLanguage', lang)
this.$message({
message: '设置语言成功',
type: 'success'
})
}
}
}
</script>
- 登录界面的国际化
<template>
<div class="login">
<div style="right: 0;top: 0;position: absolute;">
<lang-select />
</div>
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form">
<h3 class="title">{{ $t('login.title') }}</h3>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
type="text"
auto-complete="off"
:placeholder="$t('login.username')"
>
<svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon"/>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
auto-complete="off"
:placeholder="$t('login.password')"
@keyup.enter.native="handleLogin"
>
<svg-icon slot="prefix" icon-class="password" class="el-input__icon input-icon"/>
</el-input>
</el-form-item>
<el-form-item prop="code" v-if="captchaOnOff">
<el-input
v-model="loginForm.code"
auto-complete="off"
placeholder="验证码"
style="width: 63%"
@keyup.enter.native="handleLogin"
>
<svg-icon slot="prefix" icon-class="validCode" class="el-input__icon input-icon"/>
</el-input>
<div class="login-code">
<img :src="codeUrl" @click="getCode" class="login-code-img"/>
</div>
</el-form-item>
<el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">{{
$t('login.rememberMe')
}}
</el-checkbox>
<el-form-item style="width:100%;">
<el-button
:loading="loading"
size="medium"
type="primary"
style="width:100%;"
@click.native.prevent="handleLogin"
>
<span v-if="!loading">{{ $t('login.logIn') }}</span>
<span v-else>登 录 中...</span>
</el-button>
<div style="float: right;" v-if="register">
<router-link class="link-type" :to="'/register'">立即注册</router-link>
</div>
<div style="margin-top: 30px;">
<el-divider>
其他方式登录
</el-divider>
</div>
</div>
</el-form-item>
</el-form>
<!-- 底部 -->
<div class="el-login-footer">
<span>Copyright © 2021-2022 wangqingjiang All Rights Reserved.</span>
<br>
<a href="https://beian.miit.gov.cn/">
<span>蜀ICP备2021028494号</span>
</a>
</div>
</div>
</template>
<script>
import {getCodeImg, PreLoginByGitee} from "@/api/login";
import Cookies from "js-cookie";
import {decrypt, encrypt} from '@/utils/jsencrypt'
import LangSelect from "@/components/LangSelect";
export default {
name: "Login",
components: {LangSelect},
data() {
return {
codeUrl: "",
loginForm: {
username: "",
password: "",
rememberMe: false,
code: "",
uuid: ""
},
loginRules: {
username: [
{required: true, trigger: "blur", message: "请输入您的账号"}
],
password: [
{required: true, trigger: "blur", message: "请输入您的密码"}
],
code: [{required: true, trigger: "change", message: "请输入验证码"}]
},
loading: false,
// 验证码开关
captchaOnOff: true,
// 注册开关
register: true,
redirect: undefined
};
},
watch: {
$route: {
handler: function (route) {
this.redirect = route.query && route.query.redirect;
},
immediate: true
},
},
created() {
this.getCode();
this.getCookie();
},
methods: {
giteeLogin() {
PreLoginByGitee().then(res => {
Cookies.set("user-uuid", res.uuid)
window.location = res.authorizeUrl
})
},
qqLogin() {
this.$message.info("暂不支持QQ登录")
},
getCode() {
getCodeImg().then(res => {
this.captchaOnOff = res.captchaOnOff === undefined ? true : res.captchaOnOff;
if (this.captchaOnOff) {
this.codeUrl = "data:image/gif;base64," + res.img;
this.loginForm.uuid = res.uuid;
}
});
},
getCookie() {
const username = Cookies.get("username");
const password = Cookies.get("password");
const rememberMe = Cookies.get('rememberMe')
this.loginForm = {
username: username === undefined ? this.loginForm.username : username,
password: password === undefined ? this.loginForm.password : decrypt(password),
rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
};
},
handleLogin() {
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true;
if (this.loginForm.rememberMe) {
Cookies.set("username", this.loginForm.username, {expires: 30});
Cookies.set("password", encrypt(this.loginForm.password), {expires: 30});
Cookies.set('rememberMe', this.loginForm.rememberMe, {expires: 30});
} else {
Cookies.remove("username");
Cookies.remove("password");
Cookies.remove('rememberMe');
}
this.$store.dispatch("Login", this.loginForm).then(() => {
this.$router.push({path: this.redirect || "/"}).catch(() => {
});
}).catch(() => {
this.loading = false;
if (this.captchaOnOff) {
this.getCode();
}
});
}
});
}
}
};
</script>
<style rel="stylesheet/scss" lang="scss">
.login {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
background-image: url("../assets/images/login-background.jpg");
background-size: cover;
}
.title {
margin: 0px auto 30px auto;
text-align: center;
color: #707070;
}
.login-form {
border-radius: 6px;
background: #ffffff;
width: 400px;
padding: 25px 25px 5px 25px;
.el-input {
height: 38px;
input {
height: 38px;
}
}
.input-icon {
height: 39px;
width: 14px;
margin-left: 2px;
}
}
.login-tip {
font-size: 13px;
text-align: center;
color: #bfbfbf;
}
.login-code {
width: 33%;
height: 38px;
float: right;
img {
cursor: pointer;
vertical-align: middle;
}
}
.el-login-footer {
height: 50px;
line-height: 20px;
position: fixed;
bottom: 0;
width: 100%;
text-align: center;
color: #fff;
font-family: Arial;
font-size: 12px;
letter-spacing: 1px;
}
.login-code-img {
height: 38px;
}
</style>
9、定时任务
在实际项目开发中Web应用有一类不可缺少的,那就是定时任务。定时任务的场景可以说非常广泛,比如某些视频网站,购买会员后,每天会给会员送成长值,每月会给会员送一些电影券;比如在保证最终一致性的场景中,往往利用定时任务调度进行一些比对工作;比如一些定时需要生成的报表、邮件;比如一些需要定时清理数据的任务等。所以我们提供方便友好的web界面,实现动态管理任务,可以达到动态控制定时任务启动、暂停、重启、删除、添加、修改等操作,极大地方便了开发过程。
9、定时任务
在实际项目开发中Web应用有一类不可缺少的,那就是定时任务。定时任务的场景可以说非常广泛,比如某些视频网站,购买会员后,每天会给会员送成长值,每月会给会员送一些电影券;比如在保证最终一致性的场景中,往往利用定时任务调度进行一些比对工作;比如一些定时需要生成的报表、邮件;比如一些需要定时清理数据的任务等。所以我们提供方便友好的web界面,实现动态管理任务,可以达到动态控制定时任务启动、暂停、重启、删除、添加、修改等操作,极大地方便了开发过程。
待后续更新定时任务使用方式