内容概述

  • 短信登录

    • 这部分会使用Redis共享session来实现
  • 但其实我在之前的瑞吉外卖的项目优化部分就做过了,用Redis替换session来存储邮箱验证码

  • 商户查询缓存

    • 这部分要理解缓存击穿,缓存穿透,缓存雪崩等问题,对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容
  • 优惠券秒杀

    • 这部分我们可以学会Redis的计数器功能,结合Lua(之前一直想学Lua然后写饥荒mod)完成高性能的Redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列
  • 附近的商户

    • 利用Redis的GEOHash(新数据结构,前面没有应用场景就没介绍)来完成对于地理坐标的操作
  • UV统计

    • 主要是使用Redis来完成统计功能
  • 用户签到

    • 使用Redis的BitMap数据统计功能
  • 好友关注

    • 基于Set集合的关注、取消关注,共同关注等等功能,这部分在上篇的练习题中出现过,这次我们在项目中来使用一下
  • 达人探店

    • 基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能

短信登录

导入项目

在实现功能之前,我们先来导入项目,让项目跑起来

导入SQL

黑马已经在资料中提供好了SQL文件,这里简单分析一下提供的表

说明
tb_user 用户表
tb_user_info 用户详情表
tb_shop 商户信息表
tb_shop_type 商户类型表
tb_blog 用户日记表(达人探店日记)
tb_follow 用户关注表
tb_voucher 优惠券表
tb_voucher_order 优惠券的订单表

有关当前模型

  • 该项目采用的是前后端分离开发模式
  • 手机或者app端发起请求,请求我们的Nginx服务器,Nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开Tomcat访问Redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游Tomcat服务器,打散流量,我们都知道一台4核8G的Tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过Nginx的负载均衡分流后,利用集群支撑起整个项目,同时Nginx在部署了前端项目后,更是可以做到动静分离,进一步降低Tomcat服务的压力,这些功能都得靠Nginx起作用,所以Nginx是整个项目中重要的一环。
  • 在Tomcat支撑起并发流量后,我们如果让Tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。

img

导入后端项目

  • 黑马已经提供好了后端项目源码压缩包,我们将其解压之后,放到自己的workspace里
  • 然后修改MySQL和Reids的连接要素为自己的,随后启动项目
  • 访问http://localhost:8081/shop-type/list ,如果可以看到JSON数据,则说明导入成功

导入前端工程

  • 黑马已经提供好了前端项目源码压缩包,我们将其解压之后,放到自己的workSpace里
  • 然后在nginx所在目录打开一个cmd窗口,输入命令,即可启动项目
1
start nginx.exe 

基于Session实现登录流程

  1. 发送验证码
    用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
    如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
  2. 短信验证码登录、注册
    用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
  3. 校验登录状态
    用户在请求的时候,会从cookie中携带JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并放行

实现发送短信验证码功能

  • 输入手机号,点击发送验证码按钮,查看发送的请求

请求网址: http://localhost:8080/api/user/code?phone=15832165478
请求方法: POST

  • 看样子是调用UserController中的code方法,携带参数是phone,看黑马提供的源码也证实了我的猜想
1
2
3
4
5
6
7
8
9
   /**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return Result.fail("功能未完成");
}

  • 但是黑马这里并不会真的使用短信服务发送验证码,只是随机生成了一个验证码,那我这里为了后期项目能真的部署上线,还是打算用邮箱验证
  • 由于黑马这里貌似没有设置前端的手机号正则判断,所以我们只需要去数据库中修改phone的字段类型,将varchar(11)改为varchar(100)
  • 导入邮箱验证需要的maven坐标
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- https://mvnrepository.com/artifact/javax.activation/activation -->
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/javax.mail/mail -->
<dependency>
<groupId>javax.mail</groupId>
<artifactId>mail</artifactId>
<version>1.4.7</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-email -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-email</artifactId>
<version>1.4</version>
</dependency>

  • 然后编写一个工具类,用于发送邮件验证码
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Properties;

import javax.mail.Authenticator;
import javax.mail.MessagingException;
import javax.mail.PasswordAuthentication;
import javax.mail.Session;
import javax.mail.Transport;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMessage.RecipientType;

public class MailUtils {
public static void main(String[] args) throws MessagingException {
//可以在这里直接测试方法,填自己的邮箱即可
sendTestMail("1586385296@qq.com", new MailUtils().achieveCode());
}

public static void sendTestMail(String email, String code) throws MessagingException {
// 创建Properties 类用于记录邮箱的一些属性
Properties props = new Properties();
// 表示SMTP发送邮件,必须进行身份验证
props.put("mail.smtp.auth", "true");
//此处填写SMTP服务器
props.put("mail.smtp.host", "smtp.qq.com");
//端口号,QQ邮箱端口587
props.put("mail.smtp.port", "587");
// 此处填写,写信人的账号
props.put("mail.user", "1586385296@qq.com");
// 此处填写16位STMP口令
props.put("mail.password", "tXXXXXXXXXfgjb");
// 构建授权信息,用于进行SMTP进行身份验证
Authenticator authenticator = new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
// 用户名、密码
String userName = props.getProperty("mail.user");
String password = props.getProperty("mail.password");
return new PasswordAuthentication(userName, password);
}
};
// 使用环境属性和授权信息,创建邮件会话
Session mailSession = Session.getInstance(props, authenticator);
// 创建邮件消息
MimeMessage message = new MimeMessage(mailSession);
// 设置发件人
InternetAddress form = new InternetAddress(props.getProperty("mail.user"));
message.setFrom(form);
// 设置收件人的邮箱
InternetAddress to = new InternetAddress(email);
message.setRecipient(RecipientType.TO, to);
// 设置邮件标题
message.setSubject("Kyle's Blog 邮件测试");
// 设置邮件的内容体
message.setContent("尊敬的用户:你好!\n注册验证码为:" + code + "(有效期为一分钟,请勿告知他人)", "text/html;charset=UTF-8");
// 最后当然就是发送邮件啦
Transport.send(message);
}

public static String achieveCode() { //由于数字 1 、 0 和字母 O 、l 有时分不清楚,所以,没有数字 1 、 0
String[] beforeShuffle = new String[]{"2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F","G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a","b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v","w", "x", "y", "z"};
List<String> list = Arrays.asList(beforeShuffle);//将数组转换为集合
Collections.shuffle(list); //打乱集合顺序
StringBuilder sb = new StringBuilder();
for (String s : list) {
sb.append(s); //将集合转化为字符串
}
return sb.substring(3, 8);
}
}

  • 修改sendCode方法,逻辑如下

    • 验证手机号/邮箱格式
      • 不正确则返回错误信息
  • 正确则发送验证码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 发送手机验证码
*/
@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) throws MessagingException {
// TODO 发送短信验证码并保存验证码
if (RegexUtils.isEmailInvalid(phone)) {
return Result.fail("邮箱格式不正确");
}
String code = MailUtils.achieveCode();
session.setAttribute(phone, code);
log.info("发送登录验证码:{}", code);
MailUtils.sendTestMail(phone, code);
return Result.ok();
}

  • 然后输入邮箱,发送验证码,看看能否接收到验证码
  • 测试没有问题之后,我们继续来编写登录功能,点击登录按钮,查看发送的请求

请求网址: http://localhost:8080/api/user/login
请求方法: POST

  • 看样子是UserController中的login方法,携带的参数也就是我们的邮箱和验证码
1
{phone: "1586385296@qq.com", code: "iMPKc"} 
  • 黑马提供的代码如下,看样子是把邮箱和验证码封装到了LoginFormDto中
  • login
1
2
3
4
5
6
7
8
9
10
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// TODO 实现登录功能
return Result.fail("功能未完成");
}

  • LoginFormDTO
1
2
3
4
5
6
@Data
public class LoginFormDTO {
private String phone;
private String code;
private String password;
}
  • 修改login方法,逻辑如下

    • 校验手机号/邮箱
      • 不正确则返回错误信息
  • 正确则继续校验验证码

        • 不一致则报错
  • 一致则先根据手机号/邮箱查询用户

          • 用户不存在则创建
  • 存在则继续执行程序

        • 保存用户信息到session中
  • login

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
/**
* 登录功能
*
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//获取登录账号
String phone = loginForm.getPhone();
//获取登录验证码
String code = loginForm.getCode();
//获取session中的验证码
Object cacheCode = session.getAttribute(phone);

//1. 校验邮箱
if (RegexUtils.isEmailInvalid(phone)) {
//2. 不符合格式则报错
return Result.fail("邮箱格式不正确!!");
}
//3. 校验验证码
log.info("code:{},cacheCode{}", code, cacheCode);
if (code == null || !cacheCode.toString().equals(code)) {
//4. 不一致则报错
return Result.fail("验证码不一致!!");
}
//5. 根据账号查询用户是否存在
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
//6. 如果不存在则创建
if (user == null) {
// 创建的逻辑封装成了一个方法
user = createUserWithPhone(phone);
}
//7. 保存用户信息到session中
session.setAttribute("user", user);
return Result.ok();
}

  • createUserWithPhone
1
2
3
4
5
6
7
8
9
10
11
private User createUserWithPhone(String phone) {
//创建用户
User user = new User();
//设置手机号
user.setPhone(phone);
//设置昵称(默认名),一个固定前缀+随机字符串
user.setNickName("user_" + RandomUtil.randomString(8));
//保存到数据库
userService.save(user);
return user;
}

实现登录拦截功能

  • 这部分需要用到拦截器的知识,我在前面的SSM整合篇做过详细介绍

SSM整合https://cyborg2077.github.io/2022/09/10/SSMIntegration/

  • 创建一个LoginInterceptor类,实现HandlerInterceptor接口,重写其中的两个方法,前置拦截器和完成处理方法,前置拦截器主要用于我们登陆之前的权限校验,完成处理方法是用于处理登录后的信息,避免内存泄露
  • LoginInterceptor
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
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取session
HttpSession session = request.getSession();
//2. 获取session中的用户信息
User user = (User) session.getAttribute("user");
//3. 判断用户是否存在
if (user == null) {
//4. 不存在,则拦截
response.setStatus(401);
return false;
}
//5. 存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
UserHolder.saveUser(user);
//6. 放行
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}

  • UserHolder

这是黑马已经提供好了的一个工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class UserHolder {
private static final ThreadLocal<User> tl = new ThreadLocal<>();

public static void saveUser(User user){
tl.set(user);
}

public static User getUser(){
return tl.get();
}

public static void removeUser(){
tl.remove();
}
}
  • MvcConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
);
}
}
  • 顺便再写一下me方法
1
2
3
4
5
6
7
@GetMapping("/me")
public Result me() {
// TODO 获取当前登录的用户并返回
User user = UserHolder.getUser();
return Result.ok(user);
}

隐藏用户敏感信息

  • 我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"success":true,
"data":{
"id":1010,
"phone":"1586385296@qq.com",
"password":"",
"nickName":"user_i1b3ir09",
"icon":"",
"createTime":"2022-10-22T14:20:33",
"updateTime":"2022-10-22T14:20:33"
}
}

  • UserDto类如下,将User对象中的属性拷贝给UserDto,就可以避免暴露用户的隐藏信息
1
2
3
4
5
6
7
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}

  • 修改UserHolder,将其User类型都换为UserDto
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

public static void saveUser(UserDTO user){
tl.set(user);
}

public static UserDTO getUser(){
return tl.get();
}

public static void removeUser(){
tl.remove();
}
}

  • 修改login方法_DIFF
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
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//获取登录账号
String phone = loginForm.getPhone();
//获取登录验证码
String code = loginForm.getCode();
//获取session中的验证码
Object cacheCode = session.getAttribute(phone);

//1. 校验邮箱
if (RegexUtils.isEmailInvalid(phone)) {
//2. 不符合格式则报错
return Result.fail("邮箱格式不正确!!");
}
//3. 校验验证码
log.info("code:{},cacheCode{}", code, cacheCode);
if (code == null || !cacheCode.toString().equals(code)) {
//4. 不一致则报错
return Result.fail("验证码不一致!!");
}
//5. 根据账号查询用户是否存在
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
//6. 如果不存在则创建
if (user == null) {
user = createUserWithPhone(phone);
}
//7. 保存用户信息到session中
- session.setAttribute("user", user);
+ UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
+ session.setAttribute("user", userDTO);
return Result.ok();
}

  • 修改拦截器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取session
HttpSession session = request.getSession();
//2. 获取session中的用户信息
- User user = (User) session.getAttribute("user");
+ UserDTO user = (UserDTO) session.getAttribute("user");
//3. 判断用户是否存在
if (user == null) {
//4. 不存在,则拦截
response.setStatus(401);
return false;
}
//5. 存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
UserHolder.saveUser(user);
//6. 放行
return true;
}

  • 重启服务器,登录后查看此时的用户信息,敏感信息已经不存在了
1
2
3
4
5
6
7
8
9
{
"success":true,
"data":{
"id":1016,
"nickName":"user_zkhf7cfv",
"icon":""
}
}

session共享问题

  • 每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
  • 但是这种方案具有两个大问题
    1. 每台服务器中都有完整的一份session数据,服务器压力过大。
  1. session拷贝数据时,可能会出现延迟
  • 所以我们后面都是基于Redis来完成,我们把session换成Redis,Redis数据本身就是共享的,就可以避免session共享的问题了

Redis替代session的业务流程

设计key结构

  • 首先我们来思考一下该用什么数据结构来存储数据

  • 由于存入的数据比较简单,我们可以使用String或者Hash

    • 如果使用String,以JSON字符串来保存数据,会额外占用部分空间
  • 如果使用Hash,则它的value中只会存储数据本身

  • 如果不是特别在意内存,直接使用String就好了

设计key的具体细节

  • 我们这里就采用的是简单的K-V键值对方式
  • 但是对于key的处理,不能像session一样用phone或code来当做key
  • 因为Redis的key是共享的,code可能会重复,phone这种敏感字段也不适合存储到Redis中
  • 在设计key的时候,我们需要满足两点
    1. key要有唯一性
  1. key要方便携带
  • 所以我们在后台随机生成一个token,然后让前端带着这个token就能完成我们的业务逻辑了

整体访问流程

  • 当注册完成后,用户去登录,然后校验用户提交的手机号/邮箱和验证码是否一致

    • 如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到Redis,并生成一个token作为Redis的key
  • 当我们校验用户是否登录时,回去携带着token进行访问,从Redis中获取token对应的value,判断是否存在这个数据

    • 如果不存在,则拦截
  • 如果存在,则将其用户信息(userDto)保存到threadLocal中,并放行

基于Redis实现短信登录

  • 由于前面已经分析过业务逻辑了,所以这里我们直接开始写代码,在此之前我们要在UserController中注入StringRedisTemplate
1
2
@Autowired
private StringRedisTemplate stringRedisTemplate;
  • 修改sendCode方法
  • 修改sendCode方法

这里的key使用用login:code:email的形式,并设置有效期2分钟,我们也可以定义一个常量类来替换这里的login:code:和2,让代码显得更专业一点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) throws MessagingException {
// TODO 发送短信验证码并保存验证码
if (RegexUtils.isEmailInvalid(phone)) {
return Result.fail("邮箱格式不正确");
}
String code = MailUtils.achieveCode();
- session.setAttribute(phone, code);
- stringRedisTemplate.opsForValue().set("login:code:" + phone, code, 2, TimeUnit.MINUTES);
+ stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
log.info("发送登录验证码:{}", code);
// MailUtils.sendTestMail(phone, code);
return Result.ok();
}

  • RedisConstants

定义的常量类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 36000L;

public static final Long CACHE_NULL_TTL = 2L;

public static final Long CACHE_SHOP_TTL = 30L;
public static final String CACHE_SHOP_KEY = "cache:shop:";

public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;

public static final String SECKILL_STOCK_KEY = "seckill:stock:";
public static final String BLOG_LIKED_KEY = "blog:liked:";
public static final String FEED_KEY = "feed:";
public static final String SHOP_GEO_KEY = "shop:geo:";
public static final String USER_SIGN_KEY = "sign:";
}
  • 修改login方法
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
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//获取登录账号
String phone = loginForm.getPhone();
//获取登录验证码
String code = loginForm.getCode();
- //获取session中的验证码
- Object cacheCode = session.getAttribute(phone);
+ //获取redis中的验证码
+ String sessionCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + userCode);
//1. 校验邮箱
if (RegexUtils.isEmailInvalid(phone)) {
//2. 不符合格式则报错
return Result.fail("邮箱格式不正确!!");
}
//3. 校验验证码
log.info("code:{},cacheCode{}", code, cacheCode);
if (code == null || !cacheCode.toString().equals(code)) {
//4. 不一致则报错
return Result.fail("验证码不一致!!");
}
//5. 根据账号查询用户是否存在
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
//6. 如果不存在则创建
if (user == null) {
user = createUserWithPhone(phone);
}
- //7. 保存用户信息到session中
+ //7. 保存用户信息到Redis中
+ //7.1 随机生成token,作为登录令牌
+ String token = UUID.randomUUID().toString();
+ //7.2 将UserDto对象转为HashMap存储
+ UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
+ HashMap<String, String > userMap = new HashMap<>();
+ userMap.put("icon", userDTO.getIcon());
+ userMap.put("id", String.valueOf(userDTO.getId()));
+ userMap.put("nickName", userDTO.getNickName( ));
+ //7.3 存储
+ String tokenKey = LOGIN_USER_KEY + token;
+ stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
+ //7.4 设置token有效期为30分钟
+ stringRedisTemplate.expire(tokenKey, 30, TimeUnit.MINUTES);
+ //7.5 登陆成功则删除验证码信息
+ stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
- session.setAttribute("user", userDTO);
+ //8. 返回token
+ return Result.ok(token);
}

  • 修改后代码
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
    @PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//获取登录账号
String phone = loginForm.getPhone();
//获取登录验证码
String code = loginForm.getCode();
//获取redis中的验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);

//1. 校验邮箱
if (RegexUtils.isEmailInvalid(phone)) {
//2. 不符合格式则报错
return Result.fail("邮箱格式不正确!!");
}
//3. 校验验证码
log.info("code:{},cacheCode{}", code, cacheCode);
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
//5. 根据账号查询用户是否存在
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
//6. 如果不存在则创建
if (user == null) {
user = createUserWithPhone(phone);
}
//7. 保存用户信息到session中
//7. 保存用户信息到Redis中
//7.1 随机生成token,作为登录令牌
String token = UUID.randomUUID().toString();
//7.2 将UserDto对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
HashMap<String, String > userMap = new HashMap<>();
userMap.put("icon", userDTO.getIcon());
userMap.put("id", String.valueOf(userDTO.getId()));
userMap.put("nickName", userDTO.getNickName());
//高端写法,现在我还学不来,工具类还不太了解,只能自己手动转换类型然后put了
// Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
// CopyOptions.create()
// .setIgnoreNullValue(true)
// .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
//7.3 存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
//7.4 设置token有效期为30分钟
stringRedisTemplate.expire(tokenKey, 30, TimeUnit.MINUTES);
//7.5 登陆成功则删除验证码信息
stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
//8. 返回token
return Result.ok(token);
}

解决状态登录刷新问题

初始方案

  • 我们可以通过拦截器拦截到的请求,来证明用户是否在操作,如果用户没有任何操作30分钟,则token会消失,用户需要重新登录
  • 通过查看请求,我们发现我们存的token在请求头里,那么我们就在拦截器里来刷新token的存活时间

authorization: 6867061d-a8d0-4e60-b92f-97f7d698a1ca

  • 修改我们的登陆拦截器LoginInterceptor类
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
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取请求头中的token
String token = request.getHeader("authorization");
//2. 如果token是空,则未登录,拦截
if (StrUtil.isBlank(token)) {
response.setStatus(401);
return false;
}
String key = RedisConstants.LOGIN_USER_KEY + token;
//3. 基于token获取Redis中的用户数据
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//4. 判断用户是否存在,不存在,则拦截
if (userMap.isEmpty()) {
response.setStatus(401);
return false;
}
//5. 将查询到的Hash数据转化为UserDto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6. 将用户信息保存到ThreadLocal
UserHolder.saveUser(userDTO);
//7. 刷新tokenTTL,这里的存活时间根据需要自己设置,这里的常量值我改为了30分钟
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}

  • 在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的

imgimg

优化方案

  • 既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

imgimg

  • 新建一个RefreshTokenInterceptor类,其业务逻辑与之前的LoginInterceptor类似,就算遇到用户未登录,也继续放行,交给LoginInterceptor处理
    由于这个对象是我们手动在WebConfig里创建的,所以这里不能用@AutoWired自动装配,只能声明一个私有的,到了WebConfig里再自动装配
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
public class RefreshTokenInterceptor implements HandlerInterceptor {
//这里并不是自动装配,因为RefreshTokenInterceptor是我们手动在WebConfig里new出来的
private StringRedisTemplate stringRedisTemplate;

public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取请求头中的token
String token = request.getHeader("authorization");
//2. 如果token是空,直接放行,交给LoginInterceptor处理
if (StrUtil.isBlank(token)) {
return true;
}
String key = RedisConstants.LOGIN_USER_KEY + token;
//3. 基于token获取Redis中的用户数据
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//4. 判断用户是否存在,不存在,也放行,交给LoginInterceptor
if (userMap.isEmpty()) {
return true;
}
//5. 将查询到的Hash数据转化为UserDto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6. 将用户信息保存到ThreadLocal
UserHolder.saveUser(userDTO);
//7. 刷新tokenTTL,这里的存活时间根据需要自己设置,这里的常量值我改为了30分钟
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}

  • 修改我们之前的LoginInterceptor类,只需要判断用户是否存在,不存在,则拦截,存在则放行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class LoginInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断用户是否存在
if (UserHolder.getUser()==null){
//不存在则拦截
response.setStatus(401);
return false;
}
//存在则放行
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}

  • 修改WebConfig配置类,拦截器的执行顺序可以由order来指定,如果未设置拦截路径,则默认是拦截所有路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class MvcConfig implements WebMvcConfigurer {
//到了这里才能自动装配
@Autowired
private StringRedisTemplate stringRedisTemplate;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
).order(1);
//RefreshTokenInterceptor是我们手动new出来的
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
}
}

  • 那么至此,大功告成,我们重启服务器,登录,然后去Redis的图形化界面查看token的ttl,如果每次切换界面之后,ttl都会重置,那么说明我们的代码没有问题