黑马点评项目-短信登录功能

一、导入黑马点评项目

1、代码下载

视频资源链接:P25 实战篇-02.短信登录-导入黑马点评项目
代码可以直接去黑马微信公众号上搜索,或者从下面的网盘链接中下载:链接:
https://pan.baidu.com/s/1aWhWVn2Ai7AeuDm0KftSqw
提取码: snuw

2、数据库

2.1 SQL 脚本执行

上面的链接中有一个 hmdp.sql 文件,注意在执行该 SQL 脚本的时候不要批量执行,会报错,最好是一条一条执行。

2.2 数据库表介绍

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

3、前端 Nginx 搭建

3.1 Mac OS 安装 Nginx

我自己用的是 Mac 电脑,原本是在 Mac 上安装了 Nginx,安装教程可以参考这位大佬写的 mac安装nginx
安装完成后,执行brew info nginx
在这里插入图片描述
注意这个服务目录,就是用来放置黑马点评项目的前端项目的。
在这里插入图片描述
配置目录中的nginx.conf,要替换成黑马点评前端项目中的 nginx.conf,当然如果不嫌麻烦或者自己对 Nginx 比较了解,也可以自己配置。
黑马点评前端项目 nginx.conf 的位置如下图所示:nginx-1.18.0/conf/nginx.conf
在这里插入图片描述
替换下面的文件,一般替换前最好是将源文件做个备份,此处可以不用备份,官方已经给我们备份好了:nginx.conf.default
在这里插入图片描述

3.2 虚拟机安装 Nginx

但是吧,这样有个问题,就是我每次关机重启后,nginx 都需要重新启动一遍,所以,我就又在虚拟机上安装了一下 Nginx,安装教程可以参考 centOS7安装nginx及nginx配置。当然也可以上网搜一搜如何将 Nginx 设置为开机自启动,这样这一步也就不用了(我已经尝试了各种方式,均以失败告终,我放弃了)。
安装完成后,也需要将黑马点评前端项目放置到服务目录,将配置目录中的 nginx.conf 替换成黑马点评前端项目的 nginx.conf。
替换过程如下:

1、先将黑马点评前端项目压缩成 hmdp.tar.gz

我是直接放在了桌面,放在哪了,下面的目录就替换成哪

tar -zcvf hmdp.tar.gz [指定压缩文件存放位置] /Users/Mac用户名/Desktop/资料/nginx-1.18.0/html/hmdp
2、将压缩包上传至虚拟机

要注意一点,如果在上述命令中未指定压缩文件存放位置,那么文件压缩后存放的位置就是自己当前所在目录,由于我没指定,最终文件生成的位置,如下图所示,就是 scp 命令后面的那个地址。
知道文件压缩后在哪了,就将文件上传至虚拟机。通过 scp 命令,Mac 可以通过终端命令直接将文件上传至虚拟机(或者服务器)。
在这里插入图片描述

再来是配置文件的替换:
在这里插入图片描述

3、登录虚拟机,解压上传的压缩包

进入到压缩包所在的目录,执行解压命令:

cd /usr/local/nginx/html   ## 进入到压缩包所在的目录
ll                         ## 查看当前目录下的所有文件
tar -zxvf hmdp.tar.gz      ## 解压

不知道是不是我压缩的有问题,我的压缩文件将其父目录也压缩进去了
在这里插入图片描述

执行下面的命令:

mv hmdp /usr/local/nginx/html  ## 将前端项目移动到/usr/local/nginx/html下
rm -rf Users   ## 删除 Users 文件夹

3.3 修改 Nginx 的配置文件 nginx.conf

由于视频中 Nginx 和 Java 代码都是在同一个服务器上,所以 nginx.conf 中的服务器地址用的是 127.0.0.1,但是现在由于我将 Nginx 和 Java 代码分开来放了,就需要修改服务器地址了,将其修改成 Java 代码所部署的服务器地址,即 Mac 本身的 IP 地址。
在这里插入图片描述

3.4 启动 Nginx

cd /usr/local/nginx/sbin
./nginx

3.5 防火墙开启 8080 端口

firewall-cmd --permanent --add-port=8080/tcp  ## 添加端口
service firewalld restart   ## 重启防火墙

四、项目结构介绍

在这里插入图片描述
DTO(data transfer object):数据传输对象
是一种设计模式之间传输数据的软件应用系统,数据传输目标往往是数据访问对象从数据库中检索数据
数据传输对象与数据交互对象或数据访问对象之间的差异是一个以不具任何行为除了存储和检索的数据(访问和存取器)
简而言之,就是接口之间传递的数据封装
表里面有十几个字段:id,name,gender(M/F),age……
页面需要展示三个字段:name,gender(男/女),age
DTO由此产生,一是能提高数据传输的速度(减少了传输字段),二能隐藏后端表结构
具体可以查看这篇文章关于JAVA Bean实体类对象pojo,vo,po,dto,entity之间的区别,了解entity 和 DTO 之间的具体区别。

五、基础代码

基础代码不再过多介绍了

六、基于 session 实现短信登录

6.1 登录流程分析

在这里插入图片描述

6.2 实现发送验证码

在这里插入图片描述

UserController 类
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private IUserService userService;

    /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 发送短信验证码并保存验证码
        return userService.sendCode(phone, session);
    }
}

UserService 接口
public interface IUserService extends IService<User> {
    Result sendCode(String phone, HttpSession session);
}
UserServiceImpl 实现类
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1、校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2、如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        // 3、符合,生成验证码
        String code = RandomUtil.randomNumbers(6);
        // 4、保存验证码到 session
        session.setAttribute("code", code);
        // 5、发送验证码
        log.debug("发送短信验证码成功,验证码:{}", code);
        return Result.ok();
    }
}

6.3 实现短信验证码登录和注册功能

在这里插入图片描述

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

	@Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1、校验手机号
        String phone = loginForm.getPhone();
        String code = loginForm.getCode();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式错误!");
        }

        // 2、校验验证码
        String cacheCode = (String) session.getAttribute("code");
        // 3、不一致,报错
        if(cacheCode == null || !cacheCode.equals(code)){
            return Result.fail("验证码错误!");
        }
        // 4、一致,根据手机号去查询用户
        User user = query().eq("phone", phone).one();

        // 5、判断用户是否存在
        if(user == null){
            // 6、不存在,创建新用户并保存
            user = createUserWithPhone(phone);
        }

        // 7、保存用户信息到 session 中
        session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
        return Result.ok();
    }

	/**
     * 根据手机号创建用户
     * */
	private User createUserWithPhone(String phone) {
        // 创建用户
        User user = new User();
        user.setPhone(phone);
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
        // 保存用户
        save(user);
        return user;
    }
}

此处登录后会跳转到主页,而不是个人信息页面,这个是因为前端页面在登录后将跳转页面写成了 index.html,手动改一下即可。
在这里插入图片描述

6.4 登录校验拦截器

登录方法虽然已经创建完成,但是通过实际验证,此时依然无法登录,会直接跳转回登录页面。打开 info.html 页面,我们来分析下:
在这里插入图片描述
可以看到在 script 代码块中,当 Vue 实例化时,调用了一个 created() 函数,该函数又调用了 queryUser() 方法。
来看下 created() 函数的作用:
vue.js中created方法是一个生命周期钩子函数,一个vue实例被生成后会调用这个函数。一个vue实例被生成后还要绑定到某个html元素上,之后还要进行编译,然后再插入到document中。每一个阶段都会有一个钩子函数,方便开发者在不同阶段处理不同逻辑。
原文Vue进阶(三十六):created() 详解
也就是说在 Vue 实例化后,其实就直接调用了 queryUser() 方法,该方法调用了后台接口 /user/me,如果该接口没有获取到用户信息,则直接跳转至登录页面。该接口我们目前并没有进行实现,所以也就获取不到用户信息。现在来思考下如何实现这个接口。首先,这个接口没有任何的入参,也就是说我们没法根据用户的 id 或者手机号去数据库中获取,那我们要如何获取当前用户的信息?我们在登录时,其实已经向 session 中保存了用户信息,当然可以从 session 中直接获取。我们现在考虑另一种实现方式。
项目中部分模块的部分页面在用户未登录的状态是可以进行浏览的,但是还有部分模块的部分页面是必须在登录后才可以查看,比如个人信息页面。我们不可能对所有的模块都单独添加判断登录状态逻辑,那可不可以将登录状态的判断逻辑抽取出来形成一个公共方法,所有页面在访问时都需要先进性判断登录状态,符合条件的则放行,不符合条件的则跳转至登录页面。有一种方式可以实现,就是拦截器。我们通过拦截器拦截用户的所有请求,判断用户是否已经登录,如果已经完成登录,则将用户信息放到 ThreadLocal 中,保证每个请求可以获取到专属于它自己的用户信息,保证不同请求以及不同用户之间不会相互干扰。当用户已经处于登录状态,在我们访问 /user/me 接口时,自然也要进行判断,然后获取用户信息时,则可以直接从 ThreadLocal 中获取,而不是从 session 中获取。

知识点补充:
在这里插入图片描述
Session 是如何知道当前登录用户是哪一个用户的?
HTTP 是无状态的协议。所谓无状态,是指当一个浏览器客户程序与服务器之间多次进行基于 HTTP 请求/响应模式的通信时,HTTP 协议本身没有提供服务器连续跟踪特定浏览器端状态的规范。即 HTTP 协议本身是不会记录当前登录用户的信息的,所以当大量的用户去访问同一个页面时是无法通过 HTTP 协议去判断每个请求是由哪一个用户发出的。但是,我们又必须要区分出是哪一用户操作的。在 Web 开发领域,会话机制是用于跟踪客户状态的普遍解决方案。会话是指在一段时间内,单个客户与 Web 应用的一连串相关的交互过程。在一个会话中,客户可能会多次请求访问 Web 应用的同一个网页,也有可能请求访问同一个 Web 应用中的多个网页。
HTTP 会话为跟踪客户状态提供了统一的解决方案,SessionID 就是 HTTP 请求中用于跟踪客户状态的额外数据。当浏览器第一次请求访问应用中的任意一个支持会话的网页时,Servlet 容器试图寻找 HTTP 请求中表示 Session ID 的 Cookie,由于还不存在这样的 Cookie,因此就认为一个新的会话开始了,于是创建一个 HttpSession 对象,为它分配唯一的 Session ID,然后把 Session ID 作为 Cookie 添加到 HTTP 响应结果中。浏览器接收到 HTTP 响应结果后,会把其中表示 Session ID 的 Cookie 保存在客户端。当浏览器继续访问应用中的任意一个支持会话的网页时,在本次的 HTTP 请求中会包含表示 Session ID 的 Cookie。Servlet 容器视图寻找 HTTP 请求中表示 Session ID 的 Cookie,由于此时能获得这样的 Cookie,因此认为本次请求已经处于一个会话中,Servlet 容器不再创建新的 HttpSession 对象,而是从 Cookie 中获取 Session ID,然后根据 SessionID 找到内存中对应的 HttpSession 对象。

总结一下:
Session 是记录在服务器端的,用于跟踪用户状态的数据,而 Cookie 则是保存在客户端浏览器的用来保存用户信息的数据,属于 Session 的一种实现方式。Cookie 会记录每一个 Session 的 Session ID,该 Session ID 是唯一的,服务器可以根据该 Session ID 拿到对应的 Session。

代码实现

LoginInterceptor
package com.hmdp.utils;
import com.hmdp.entity.User;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

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 获取用户信息、
        Object user = session.getAttribute("user");

        // 3、判断用户是否存在
        if(user == null){
            // 不存在报401
            response.setStatus(401);
            return false;
        }

        // 存在,将其保存到 ThreadLocal 中
        UserHolder.saveUser((User) user);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    	// 移除用户,避免内存泄露
    	// 视图渲染完成后执行 afterCompletion 方法,也就是说一次请求获取一次用户信息,用完立即释放
        UserHolder.removeUser();
    }
}

UserHolder
package com.hmdp.utils;

import com.hmdp.entity.User;

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

拦截器创建完成后并没有生效,还需要进行配置,MvcConfig 主要就是来让拦截器生效。

SpringMVC 中的拦截器有三个抽象方法:

  • preHandle:控制器方法执行之前执行 predHandle(),其 boolean 类型的返回值表示是否拦截或放行,返回 true 为放行,即调用控制器方法;返回 false 表示拦截,即不调用控制器方法。

  • postHandle:控制器方法执行之后执行 postHandle()

  • afterCompletion:处理完视图和模型数据,渲染视图完毕之后执行 afterCompletion()

原文:Spring MVC 之拦截器

package com.hmdp.config;

import com.hmdp.utils.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MvcConfig implements WebMvcConfigurer {

	// addInterceptors 添加拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    	// 添加拦截器并排除不需要拦截的路径,即不用登录也可以访问的页面
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/user/login",
                        "/user/code",
                        "/shop-type/**",
                        "/upload/**"
                );
    }
}

UserController
package com.hmdp.controller;

import com.hmdp.dto.Result;
import com.hmdp.entity.User;
import com.hmdp.utils.UserHolder;

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/me")
    public Result me(){
        // 获取当前登录的用户并返回
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }
}

6.4 隐藏用户敏感信息

/user/me 接口获取到的用户信息,其实是一个完整的用户信息,当中包含有一些敏感信息,以及一些我们用不到的信息,这样一方面会暴露用户的隐私,产生风险,比如密码;另外一方面多余的信息会给系统增加压力。有没有什么好的办法解决这个问题呢?使用 UserDTO,将一些不需要的信息排除,只保留 User 类中我们需要用到的用户信息。

UserDTO:
package com.hmdp.dto;

import lombok.Data;

@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}
来跟 User 实体类做个对比
package com.hmdp.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_user")
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 手机号码
     */
    private String phone;

    /**
     * 密码,加密存储
     */
    private String password;

    /**
     * 昵称,默认是随机字符
     */
    private String nickName;

    /**
     * 用户头像
     */
    private String icon = "";

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;


}

可以看到 UserDTO 中去除了大量的用不到以及部分敏感的信息。

LoginInterceptor 中将 User 替换成 UserDTO
package com.hmdp.utils;

import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

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 获取用户信息、
        Object user = session.getAttribute("user");

        // 3、判断用户是否存在
        if(user == null){
            // 不存在报401
            response.setStatus(401);
            return false;
        }

        // 存在,将其保存到 ThreadLocal 中
        UserHolder.saveUser((UserDTO) user);
        return true;
    }

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

将 UserHolder 中的 User 替换成 UserDTO
package com.hmdp.utils;

import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;

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();
    }
}

将 /user/me 接口中的 User 替换成 UserDTO
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
 	@GetMapping("/me")
    public Result me(){
        // 获取当前登录的用户并返回
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }
}
UserServiceImpl 中的 login 方法
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

	@Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1、校验手机号
        String phone = loginForm.getPhone();
        String code = loginForm.getCode();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式错误!");
        }

        // 2、校验验证码
        String cacheCode = (String) session.getAttribute("code");
        // 3、不一致,报错
        if(cacheCode == null || !cacheCode.equals(code)){
            return Result.fail("验证码错误!");
        }
        // 4、一致,根据手机号去查询用户
        User user = query().eq("phone", phone).one();

        // 5、判断用户是否存在
        if(user == null){
            // 6、不存在,创建新用户并保存
            user = createUserWithPhone(phone);
        }

        // 7、保存用户信息到 session 中
        session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
        return Result.ok();
    }
}

6.5 集群的 Session 共享问题

多台 Tomcat 之间并不共享 Session 存储空间,当请求切换到不同的 Tomcat 服务时就会产生数据丢失问题。
在这里插入图片描述
当我们搭建负载均衡集群时,请求会在每个 Tomcat 之间进行轮询,假设请求第一次被负载均衡到了 Tomcat1,相关的一些用户信息就会被保存到 Tomcat1 中,那么当用户第二次请求时,如果被负载均衡到了 Tomcat2 上,此时 Tomcat2 上并没有保存用户信息,这时就会产生问题。其实,Tomcat 之间可以实现 Session 共享,只是该种方案会有以下两个问题:
1、Tomcat 之间实现 Session 共享会进行数据拷贝,多份相同的数据浪费内存空间
2、数据拷贝需要一定的时间,会产生延时,如果正好在拷贝期间进行了请求,那么同样也会访问不到,产生问题。

Session 替代方案应该满足以下三个条件:

  • 数据共享,无需进行数据拷贝
  • 内存存储,因为 Session 是基于内存,效率高,那么替代方案也应当满足该条件
  • key、value 结构,Session中的数据保存为 key、value 格式,那么替代方案也应当满足

Redis 可以满足上述要求。

6.6 基于 Redis 实现共享 Session 登录

6.6.1 分析

在这里插入图片描述
由 Session 改为 Redis 存储验证码,整体流程上没有太多变化。但是,由于 Redis 是以键值对存储的,其值存储有多种形式,那么当前情况下,哪种形式的值存储比较合适呢?由于我们需要存储的是一个 6 位数字类型的值,而 String 类型的数据结构就完全可以满足要求了。但是,键值该如何存储呢?原先 Session 存储我们以 “user" 作为 key,由于 Session 的特性(每个浏览器在发出请求时都有独立的 Session),虽然不同的用户有着相同的 key,但是不同 Session 之间相互不干扰,这就使得服务器端可以根据不同的 Session 识别出是哪个用户做出的操作。而 Redis 中的数据是共享的,如果再以 ”user“ 作为 key,不同的用户在向 Redis 中存储 value 时会产生覆盖问题,我们需要保证每个不同的手机号在做验证时保存的 key 都是不一样的,那我们就可以直接以手机号作为 key 来存储验证码,这样就可以保证每一个不同的手机号都有自己唯一的 key。这样还有一个好处就是,我们在做短信验证码登录时,手机号方便客户端携带,服务器端收到请求后,可以直接以手机号为 key 来读取验证码。
根据上面的流程图,我们在登录验证时,如果从数据库中查询到用户,还需要将用户的信息保存到 Redis 中,此时该以何种 Redis 数据结构来存储用户数据呢?来看下 String 类型和 Hash 类型的区别:
在这里插入图片描述
可以看出,Hash 结构胜出。
那么又该选用什么形式的 key 来存储用户数据呢?
在这我们选择随机的 token 作为 key 来存储用户数据。在短信验证码登录时,我们还需要将这个随机 token 返回给客户端,这是因为后期我们在访问各个页面时都是需要校验登录状态,来判断哪些页面用户可以在未登录状态下访问,哪些页面需要登录后才能访问。
在这里插入图片描述
来看下前端是如何存储 token 的。
当我们访问接口 /user/login 时,如果访问成功,会将 token 返回给前端登录页面,而前端则会将该 token 保存到 session 中(通过 sessionStorage.setItem 方法)。
在这里插入图片描述
再来看下 common.js
可以看到,common.js 中将 token 数据保存到了请求头中,该请求头的名字叫做”authorization“,这样在后续所有的 Ajax 请求中,都会在请求头中携带该 token。
在这里插入图片描述
而在此处为什么没有使用手机号作为 token 呢?这是因为 token 需要保存在客户端,如果以手机号作为 token,会有泄露用户隐私的风险。

6.6.2 代码实现

package com.hmdp.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.LoginFormDTO;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.mapper.UserMapper;
import com.hmdp.service.IUserService;
import com.hmdp.utils.RegexUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpSession;

import java.util.Map;
import java.util.concurrent.TimeUnit;

import static com.hmdp.utils.RedisConstants.*;
import static com.hmdp.utils.SystemConstants.USER_NICK_NAME_PREFIX;

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1、校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2、如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        // 3、符合,生成验证码
        String code = RandomUtil.randomNumbers(6);
        // 4、保存验证码到 Redis 中
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
        // 5、发送验证码
        log.debug("发送短信验证码成功,验证码:{}", code);
        return Result.ok();
    }

    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1、校验手机号
        String phone = loginForm.getPhone();
        String code = loginForm.getCode();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式错误!");
        }

        // 2、从 Redis 中获取验证码
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);

        // 3、不一致,报错
        if(cacheCode == null || !cacheCode.equals(code)){
            return Result.fail("验证码错误!");
        }
        // 4、一致,根据手机号去查询用户
        User user = query().eq("phone", phone).one();

        // 5、判断用户是否存在
        if(user == null){
            // 6、不存在,创建新用户并保存
            user = createUserWithPhone(phone);
        }

        // 7、将用户信息保存到 Redis 中
        // 7.1 生成 token,此处使用 UUID 作为 token
        String tokenKey = UUID.randomUUID().toString(true);
        // 7.2 将 User 对象转为 HashMap 存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
        // 7.3 将用户信息存储到 Redis 中
        stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + tokenKey, userMap);
        // 7.4 设置过期时间
        stringRedisTemplate.expire(LOGIN_USER_KEY + tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 将 token 返回给客户端
        return Result.ok(tokenKey);
    }

    /**
     * 根据手机号创建用户
     * */
    private User createUserWithPhone(String phone) {
        // 创建用户
        User user = new User();
        user.setPhone(phone);
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
        // 保存用户
        save(user);
        return user;
    }
}

代码实现分析:
1、sendCode 方法,将 session 存储验证码改为 Redis 存储,同时设置过期时间
2、login 方法中,将 session 存储用户信息,修改为 Redis 存储,value 值采用 Hash 类型,同时设置过期时间,模拟 session 过期时间。但是 session 过期是在用户未作任何操作的情况下,而 Redis 则是从用户登录开始计时,到指定时间后自动过期。我们应当保证只要用户在不断访问,就不断更新 Redis 中的 token 过期时间。那我们如何知道用户什么时候访问,有没有访问呢?我们所有的请求都要经过拦截器进行校验,只要同过了拦截器的校验就说明用户是已经登录的且在活跃的状态。那么我们就可以在拦截器中对 token 的过期时间进行刷新操作。只有什么都不操作的情况下,才不会走拦截器的校验,也就不会刷新 token 的过期时间。

修改拦截器

这里要注意一点就是,LoginInterceptor 是我们自定义的一个类,并非 Spring 进行管理的类,所以在使用 StringRedisTemplate 的时候,无法使用 @Autowired 或者 @Resource 进行注入。但是,MvcConfig 是由 Spring 进行管理的,可以由 Spring 注入 StringRedisTemplate 的实例。

package com.hmdp.utils;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class LoginInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public LoginInterceptor(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是否为空
        if (StrUtil.isBlank(token)) {
            // 不存在报401
            response.setStatus(401);
            return false;
        }
        // 3、根据 token 从 redis 中获取用户信息
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        // 使用 entries 方法获取所有的 field-value
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);

        // 4、判断 userMap 是否为空
        if (userMap.isEmpty()) {
            // 不存在报401
            response.setStatus(401);
            return false;
        }

        // 4、将 userMap 转换为 UserDTO
        UserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        // 5、将 user 保存到 ThreadLocal 中
        UserHolder.saveUser(user);

        // 6、刷新 token 过期时间
        stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

        return true;
    }

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

修改 MvcConfig 配置类

在 MvcConfig 中注入 StringRedisTemplate,同时在添加拦截器时,将 StringRedisTemplate 的实例通过 LoginInterceptor 的构造器传入。

package com.hmdp.config;

import com.hmdp.utils.LoginInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/user/login",
                        "/user/code",
                        "/shop-type/**",
                        "/upload/**"
                );
    }
}

6.6.3 解决登录时类型转换异常bug

重启项目后点击登录时,发现后台报了如下的错误。
在这里插入图片描述
这是因为 userMap 中的 id 为 Long 类型,但是 Redis 中存储的都是 String 类型。
在这里插入图片描述
login 方法改进,将 UserDTO 实例转换为 HashMap 时,将每一个属性转换为 String 类型。

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1、校验手机号
    String phone = loginForm.getPhone();
    String code = loginForm.getCode();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误!");
    }

    // 2、从 Redis 中获取验证码
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);

    // 3、不一致,报错
    if(cacheCode == null || !cacheCode.equals(code)){
        return Result.fail("验证码错误!");
    }
    // 4、一致,根据手机号去查询用户
    User user = query().eq("phone", phone).one();

    // 5、判断用户是否存在
    if(user == null){
        // 6、不存在,创建新用户并保存
        user = createUserWithPhone(phone);
    }

    // 7、将用户信息保存到 Redis 中
    // 7.1 生成 token,此处使用 UUID 作为 token
    String tokenKey = UUID.randomUUID().toString(true);
    // 7.2 将 User 对象转为 HashMap 存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
            CopyOptions.create().setIgnoreNullValue(true).
                    setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 7.3 将用户信息存储到 Redis 中
    stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + tokenKey, userMap);
    // 7.4 设置过期时间
    stringRedisTemplate.expire(LOGIN_USER_KEY + tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

    // 将 token 返回给客户端
    return Result.ok(tokenKey);
}

总结

Redis 代替 session 需要考虑的问题:

  • 选择合适的数据结构
  • 选择合适的 key
  • 选择合适的存储粒度

6.7 登录拦截器的优化

6.7.1 分析

当前拦截器:
在这里插入图片描述
登录功能是基于拦截器做的校验功能,但是当前拦截器拦截的并不是所有的路径,而是拦截的需要登录的路径,如果用户登录后,一直访问的是首页这种不需要拦截的路径,那么拦截器就会一直不执行,token 的过期时间就不会刷新,那么当 token 过期后,用户访问例如像个人主页时就会出现问题,很不友好。那如何解决?可以在当前拦截器的基础再添加一个拦截器,让新的拦截器拦截一切路径,在该拦截内做 token 的刷新动作。

优化后的拦截器:
在这里插入图片描述

6.7.2 创建 RefreshTokenInterceptor 拦截器

该拦截器用于拦截所有请求,并刷新 token 过期时间

package com.hmdp.utils;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class RefreshTokenInterceptor implements HandlerInterceptor {

    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是否为空
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 3、根据 token 从 redis 中获取用户信息
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);

        // 4、判断 userMap 是否为空
        if (userMap.isEmpty()) {
            return true;
        }

        // 4、将 userMap 转换为 UserDTO
        UserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        // 5、将 user 保存到 ThreadLocal 中
        UserHolder.saveUser(user);

        // 6、刷新 token 过期时间
        stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

        return true;
    }

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

6.7.2 修改 LoginInterceptor 拦截器

LoginInterceptor 则只需要判断当前访问用户是否已经登录,已经登录则放行,未登录则拦截

package com.hmdp.utils;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

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;
    }

}

MvcConfig 设置拦截规则

拦截器执行顺序应当是先执行 RefreshTokenInterceptor,而后再执行 LoginInterceptor,通过 orde 方法设置拦截器执行顺序,值越小,则执行顺序越优先。

package com.hmdp.config;

import com.hmdp.utils.LoginInterceptor;
import com.hmdp.utils.RefreshTokenInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.annotation.Resource;

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/user/login",
                        "/user/code",
                        "/shop-type/**",
                        "/upload/**"
                ).order(1);
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

  • 13
    点赞
  • 46
    收藏
    觉得还不错? 一键收藏
  • 25
    评论
### 回答1: 2019年黑马项目-畅购商城springcloud微服务实战是一门以实战为主的课程,旨在通过项目实践的方式,帮助学员深入理解和掌握SpringCloud微服务架构以及相关技术的应用。 课程的主要内容包括搭建基础的微服务架构、使用SpringCloud构建服务注册与发现、实现服务间的负载均衡、实现分布式配置中心、服务间的调用与容错处理、使用网关统一接入服务等。通过这些实战练习,学员不仅能够熟悉SpringCloud架构与组件,还能够了解微服务架构下的常见问题与解决方案。 畅购商城项目是一个典型的电商应用,通过实现该项目,学员可以接触到真实的业务场景与需求,并能够将所学知识应用到实际项目中。课程中通过模块化的方式逐步完善商城的功能,包括用户注册登录、商品浏览、购物车管理、订单生成与支付等。通过这些实践,学员除了掌握SpringCloud微服务的开发技术,还能够了解和掌握电商项目的开发流程和注意事项。 该课程的目标是让学员通过实战项目,全面了解和掌握SpringCloud微服务架构的设计与开发,在此基础上能够独立完成具有较高要求的微服务项目。通过参与实战项目的过程,学员还能够提升团队协作能力、解决问题的能力以及项目管理能力。 通过这门课程的学习,学员将会对SpringCloud微服务架构有更深入的理解,并能够将这些知识应用到实际项目中,提高自己在微服务开发领域的竞争力。 ### 回答2: 2019年黑马项目-畅购商城springcloud微服务实战是一个基于springcloud微服务架构的商城项目。该项目的目标是通过运用微服务的理念和技术,构建一个高可用、可扩展的商城系统。 在该项目中,使用了springcloud的多个组件,如Eureka注册中心、Feign负载均衡、Ribbon客户端负载均衡、Hystrix服务降级和容错、Zuul网关等。这些组件共同协作,实现了系统的弹性伸缩和高可用性。 畅购商城的功能包括商品展示、购物车、订单管理、支付、用户管理等。通过将这些功能拆分成独立的微服务,使得系统更加灵活和可维护。同时,使用分布式事务和消息队列来保障数据的一致性和可靠性。 在项目的开发过程中,采用了敏捷开发的方法,以迭代的方式进行开发和测试。通过使用Jenkins进行持续集成和部署,保证了代码的质量和系统的稳定性。 在项目的实战过程中,面临了许多挑战和困难,如微服务之间的通信、服务的负载均衡、服务的容错等。但通过团队的共同努力和不断的学习,最终成功地完成了该项目的开发和部署。 在该项目的实施过程中,不仅学到了springcloud微服务架构的相关知识和技术,还体会到了团队合作和解决问题的能力。该项目的成功实施,不仅为公司带来了商业价值,也提升了团队的技术水平和项目管理能力。 ### 回答3: 2019年黑马项目-畅购商城springcloud微服务实战是一个以Spring Cloud为基础的微服务项目。微服务架构是一种将应用拆分成多个小型服务的架构模式,这些服务可以独立开发、部署、扩展和管理。 畅购商城项目使用了Spring Cloud的一系列子项目,如Eureka、Ribbon、Feign、Hystrix、Zuul等,来实现各个微服务之间的通信、负载均衡、服务降级与熔断等功能。 在项目中,我们会通过Eureka来实现服务的注册与发现,每个微服务都会向Eureka注册自己的地址,其他微服务可以通过Eureka来发现并调用这些服务。而Ribbon则负责实现客户端的负载均衡,可以轮询、随机、加权等方式分发请求。 Feign是一种声明式的HTTP客户端,它简化了服务间的调用方式。我们只需编写接口,并通过注解来描述需要调用的服务和方法,Feign会自动实现远程调用。 Hystrix是一个容错机制的实现,可以通过断路器来实现服务的降级与熔断,当某个服务出现故障或超时时,Hystrix会快速响应并返回一个可控制的结果,从而保证系统的稳定性。 另外,Zuul作为微服务网关,可以实现请求的统一入口和路由转发,提高系统的安全性和性能。 通过这些Spring Cloud的组件,畅购商城项目可以实现高可用、容错、自动扩展等优质的微服务架构。 总之,2019年黑马项目-畅购商城springcloud微服务实战是一个基于Spring Cloud的微服务项目,通过使用Spring Cloud的各个子项目,可以实现微服务之间的通信、负载均衡、服务降级与熔断等功能,为项目的开发、部署和管理提供了便利。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 25
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值