【开源风云】从若依系列脚手架汲取编程之道(六)

📕开源风云系列

  • 🍊本系列将从开源名将若依出发,探究优质开源项目脚手架汲取编程之道。
  • 🍉从不分离版本开写到前后端分离版,再到微服务版本,乃至其中好玩的一系列增强Plus操作
  • 🍈希望你具备如下技术栈
    • 🍎Spring
    • 🍍SpringMVC
    • 🍐Mybatis/Mybatis-plus
    • 🍅Thymeleaf
    • 🥝SpringBoot
    • 🍓Shiro
    • 🍏SpringSecurity
    • 🍌SpringCloud
    • 🍒云服务器相关知识
  • 本篇是分离版第四篇

在这里插入图片描述

1、前端手册速过

1.1、开发规范

1.1.1、新增View

@/views文件下创建对应的文件夹,一般一个路由对应一个文件,该文件夹下有自己的utilscomponents组件供自己的文件夹使用。@/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、使用字典

字典管理是用来维护数据类型的数据,如下拉框、单选按钮、复选框、树选择的数据,方便系统管理员维护。

官方手册

使用方式也很简单:

  1. 首先加载数据字典,可以是多个
export default {
    dicts: ['字典类型']
}
  1. 翻译字典
<dict-tag :options="dict.type.字典类型" :value="scope.row.name"/>

在这里插入图片描述

效果如下

在这里插入图片描述

1.6、使用参数

参数设置是提供开发人员、实施人员的动态系统配置参数,不需要去频繁修改后台配置文件,也无需重启服务器即可生效。

this.getConfigKey("参数键名").then(response => {
  this.xxxxx = response.msg;
});

在这里插入图片描述

在这里插入图片描述

1.7、应用路径

官方文档

有些特殊情况需要部署到子路径下,例如:https://localhost/admin,按照上述官方文档修改即可。

  1. 修改vue.config.js中的publicPath属性
// 生产环境 /admin/,开发环境也是 /admin/
publicPath: process.env.NODE_ENV === "production" ? "/admin/" : "/admin/",
  1. 修改router/index.js,添加一行base属性
export default new Router({
  // 和上面的路径一致  
  base: "/admin",
  mode: 'history', // 去掉url中的#
  scrollBehavior: () => ({ y: 0 }),
  routes: constantRoutes
})
  1. /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 依赖即可。

  1. admin下引入依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
</dependency>
  1. src/test/java/com/kuang/下创建单元测试类
  2. 编写测试代码,注意在类上加上SpringBootTest注解
@SpringBootTest(classes = RuoYiApplication.class)

对于无需Spring环境的测试,可以不用加上述注解。

注意:当用单元测试在Spring环境中测试的时候,如果去访问Controller,要保证该方法能够匿名访问,并且没有@PreAuthorize注解。并且,所访问代码中不能出现需要token才能执行的代码

所以单元测试的时候,去访问的Controller类上一定要有@Anonymous匿名注解才行。

3、外置Tomcat

我们之前在部署到云服务器的时候,使用的是内嵌Tomcat。但是呢,总有一些公司需要用外置Tomcat来跑项目,下面讲述如何用外置Tomcat跑后端。

  1. 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可以用)。

  1. 修改admin模块的打包类型为 war 包,并且进行打包

在这里插入图片描述

在这里插入图片描述

这里可能会出现一些报错等,我这里是通过清理IDEA缓存,最终打包成功的。

  1. 下载 tomcat

在这里插入图片描述

  1. 找到我们打出来的war包,复制

在这里插入图片描述

  1. 打开conf/tomcat-users.xml,设置Tomcat的账号密码

在这里插入图片描述

  1. 在tomcat根目录下新建webapps-javaee文件夹,将war包丢进去,然后在bin/startup.bat 启动tomcat

在这里插入图片描述

  1. 访问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);
}
  1. 在用户第一次登录的时候,onlineUser 肯定是空,所以不会走 if 语句,直接走下面的redisCache.setCacheObject 将当前用户登录信息存进redis中,同时也将 login_tokens: loginUser.getUsername() 存入 redis中,键是 login_tokens: loginUser.getUsername() ,值也是login_tokens: loginUser.getUsername()

  2. 在用户第二次登录的时候,onlineUser 肯定非空,所以会走 if 语句前端报异常。

  3. 我们使用两个不同的浏览器依次登录admin账号,第二个浏览器登录则会提示!

在这里插入图片描述

第一次登录在 redis 中,可以看到存入的KV均为 login_tokens: loginUser.getUsername()

在这里插入图片描述

在系统登出的时候我们还要处理一下,因为登录的时候存入了KV为login_tokens: loginUser.getUsername(),登出的时候也需要在 redis 中删除。如下图进行改造即可。

在这里插入图片描述

此方式的不好之处:后面登录的人只能等待前面的人30min不操作,或者前面那个人主动登出,不然后面登录的人别想登录上去。

这样我们可以看到,在登录admin时,redis中的值如下:

在这里插入图片描述

在登出的时候,redis中的值如下:

在这里插入图片描述

上述方式一也有一个bug,演示地址:解决在线用户bug

4.2、若依官方版本手册

这里我们可以使用若依官方给的代码:如何限制账户不允许多终端登录

  1. application.yml新增一个配置soloLogin用于限制多终端同时登录。
# token配置
token:
    # 是否允许账户多终端同时登录(true允许 false不允许)
    soloLogin: false
  1. Constants.java新增一个常量LOGIN_USERID_KEY公用
/**
 * 登录用户编号 redis key
 */
public static final String LOGIN_USERID_KEY = "login_userid:";
  1. 调整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;
}
  1. 自定义退出处理类LogoutSuccessHandlerImpl.java清除缓存方法添加用户编号
// 删除用户缓存记录
tokenService.delLoginUser(loginUser.getToken(), loginUser.getUser().getUserId());
  1. 登录方法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路径下。

  1. 好了,现在可以在package.json中进行打包了:

在这里插入图片描述

  1. 打包完成会生成 dist 文件夹
  2. 复制 dist 文件夹,将其拷贝到 tomcat 的 /webapps 路径下,因为我们之前设置了应用路径/admin,所以将dist重命名为admin

在这里插入图片描述

  1. 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、后端打包

  1. admin模块的pom.xml中的打包方式由 jar 改为 war
  2. 执行Maven的 pakcage 打包命令
  3. 将 war 包放入tomcat下的 webapps-javaee 目录下
  4. 启动Tomcat

不得不说,我自己实验还是不OK… 之后再折腾吧

6、集成jsencrypt实现密码加密传输方式

目前登录接口密码是明文传输,如果安全性有要求,可以调整成加密方式传输。参考如下:

在这里插入图片描述

官方文档

  1. 进入在线生成公私钥对

在这里插入图片描述

  1. 修改前端user.js对密码进行rsa加密
import { encrypt } from '@/utils/jsencrypt'

// 登录
Login({ commit }, userInfo) {
    ...
    const password = encrypt(userInfo.password)
    ...
}

在这里插入图片描述

  1. src/utils/jsencrypt.js中将密钥对填写进去

在这里插入图片描述

  1. 后端工具类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;
        }
    }
}
  1. 登录方法SysLoginController.java,对密码进行rsa解密
String token = loginService.login(loginBody.getUsername(), RsaUtils.decryptByPrivateKey(loginBody.getPassword()), loginBody.getCode(), loginBody.getUuid());

在这里插入图片描述

  1. 测试

在这里插入图片描述

[!note]

若依的公钥和私钥是固定的,其实是不安全的,这里有随机的公钥和私钥方法:若依(RuoYi)SpringBoot框架密码加密传输(前后分离板)

7、限流控制

我们需要预防某些用户疯狂点击占用服务器资源,需要限流。ruoyi在新版中,加入了限流控制,类为RateLimiterAspect.java

在这里插入图片描述

达能我们需要限流的时候,需要加@RatyeLimiter注解,假如我们给获取用户列表的方法加上限流注解

![
在这里插入图片描述

我们进入 RateLimiter 接口,可以发现默认是限制60秒最多点击100次

在这里插入图片描述

这个限流是用 redis 来做的,我们重启项目,进入用户管理页面,然后疯狂的点击 搜索 按钮,打开 redis,可以看到当超过100次时就会提示访问频繁,进行限流了。

![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/391a4d3f0ae54420ad6cc24b94c01eed.png#p在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

默认是全局限流策略,也就是一个接口对所有用户同时限流。若改成IP限流,原理是拼接字符串的时候多拼上IP即可。

在这里插入图片描述

8、国际化

这只是部分网站的需求,想让自己的网站支持多种语言,方便外国友人一起浏览。实现中文英文随时切换。

8.1、后端国际化

  1. 后端的 resources 下面的 i18n 新建 messages_en_US.properties,英文
#错误消息
not.null=* bi xu tian xie
user.jcaptcha.error=captcha error
user.jcaptcha.expire=captcha expire
user.not.exists=user is no exist/password is error
user.password.not.match=user is no exist/password is error
user.password.retry.limit.count=user password retry {0} time(s)
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}]
  1. 再新建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}]
  1. 默认文件messages.properties保持不变

  2. 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出现同名的情况

在这里插入图片描述

  1. 在前端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

在这里插入图片描述

  1. 在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、前端国际化

视频教程

参考文档

  1. 安装依赖
# 因为用的Vue2,所以不能用9版本,Vue3用9版本
npm install vue-i18n@8   
  1. 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'
  }
}
  1. 修改 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)
})
  1. 在 src/store/getters.js 中添加 language
language: state => state.app.language,
  1. 在 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)
  },
  1. 在 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>
  1. 登录界面的国际化

<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界面,实现动态管理任务,可以达到动态控制定时任务启动、暂停、重启、删除、添加、修改等操作,极大地方便了开发过程。

待后续更新定时任务使用方式

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

生命是有光的

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值