黑马点评 - 短信验证码登录实现

黑马点评 - 短信验证码登录实现

目录

  1. [基于 Session 的实现](#基于 Session 的实现)
    • [1.1 验证码存储](#1.1 验证码存储)
    • [1.2 登录与注册](#1.2 登录与注册)
    • [1.3 登录拦截](#1.3 登录拦截)
    • [1.4 集群 Session 共享问题](#1.4 集群 Session 共享问题)
  2. [基于 Redis 的实现](#基于 Redis 的实现)
    • [2.1 发送验证码](#2.1 发送验证码)
    • [2.2 短信登录](#2.2 短信登录)
    • [2.3 登录校验](#2.3 登录校验)
    • [2.4 Token 刷新优化](#2.4 Token 刷新优化)

1. 基于 Session 的实现

1.1 验证码存储

📌 配图:验证码保存到 Session 的流程图

将生成的验证码保存到 Session 中:

java 复制代码
session.setAttribute("code", code);

1.2 登录与注册

验证码校验
java 复制代码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();

if (cacheCode == null || !cacheCode.toString().equals(code)) {
    // 不一致,报错
    return Result.fail("验证码错误");
}
数据库查询(MyBatis-Plus)

如果验证码与 Session 中的相同,则查询数据库。这里利用 MyBatis-Plus 的优势,直接调用 query 方法即可。

前提条件: 类需要先继承 ServiceImpl,并传入 Mapper 接口和实体类。

java 复制代码
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    // ...
}

Mapper 接口:

java 复制代码
public interface UserMapper extends BaseMapper<User> {
    // 继承 BaseMapper 自动获得基础 CRUD 方法
}

实体类示例:

java 复制代码
@Data                           // 1. Lombok 生成 getter/setter
@TableName("tb_user")          // 2. 指定表名
public class User implements Serializable {

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

    private String phone;       // 4. 字段名与列名对应
    private String password;
    private String nickName;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

单表查询:

java 复制代码
User user = query().eq("phone", phone).one();

自动注册:

如果查询不到该用户,则直接注册一个新用户。

Session 登录凭证:

使用 Session 实现登录时,不需要手动生成登录凭证,因为 Session 会利用 Cookie 自动实现:

  • Cookie 中存放 Session 的唯一 ID(sessionId
  • 前端后续拿用户登录凭证时,从 Cookie 中获取 sessionId,再查询 Session 即可

1.3 登录拦截

📌 配图:登录拦截器执行流程图

1.3.1 登录拦截器
java 复制代码
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) {
            // 4. 不存在,拦截,返回 401
            response.setStatus(401);
            return false;
        }

        // 5. 存在,保存用户信息到 ThreadLocal
        UserHolder.saveUser((UserDTO) user);

        // 6. 放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, 
                                Object handler, Exception ex) throws Exception {
        // 移除用户,防止内存泄漏
        UserHolder.removeUser();
    }
}
1.3.2 ThreadLocal 工具类
java 复制代码
import com.hmdp.dto.UserDTO;

/**
 * 登录拦截器 - ThreadLocal
 */
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();
    }
}

说明: 将用户信息存入 ThreadLocal,经过拦截器的请求后续可直接从 ThreadLocal 中获取用户信息。由于 ThreadLocal 是线程隔离的,每个请求对应独立的线程和 ThreadLocal 对象。请求完成后必须手动清理,避免内存泄漏。

1.3.3 拦截器配置
java 复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",   // 验证码
                        "/user/login"   // 登录
                ).order(1);

        // Token 刷新拦截器(放在登录拦截器之前)
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .excludePathPatterns(
                        "/user/code",   // 验证码
                        "/user/login"   // 登录
                )
                .addPathPatterns("/**")
                .order(0);
    }
}
1.3.4 DTO 对象转换

使用 UserDTO 替代 User 对象传输:

  • 减少敏感信息暴露
  • 降低存储压力(Session 中直接存 User 对象效率低且没必要)

对象转换工具:

java 复制代码
import cn.hutool.core.bean.BeanUtil;

// 属性拷贝
BeanUtil.copyProperties(source, target);

1.4 集群 Session 共享问题

问题 说明
数据不共享 多台 Tomcat 服务器不共享 Session 存储空间
数据丢失 请求切换到不同 Tomcat 时导致 Session 数据丢失
原因 每个 Tomcat 都有独立的 Session 存储空间

Session 替代方案应满足:

  • ✅ 数据共享
  • ✅ 内存存储
  • ✅ Key-Value 结构

解决方案: 使用 Redis 替代 Session


2. 基于 Redis的实现

📌 配图:选择Redis存储结构

2.1 发送验证码

📌 配图:Redis 保存验证码流程图

将验证码存入 Redis,Key 使用前缀 login:code: + 手机号,并设置有效期:

java 复制代码
public static final String LOGIN_CODE_KEY = "login:code:";
java 复制代码
// 4. 保存验证码到 Redis
stringRedisTemplate.opsForValue().set(
    LOGIN_CODE_KEY + phone,
    code,                                    // 验证码
    LOGIN_CODE_TTL, TimeUnit.MINUTES         // 有效期(如 2 分钟)
);

2.2 短信登录

核心流程:

  1. 校验手机号格式
  2. 从 Redis 获取验证码并校验
  3. 根据手机号查询用户(不存在则自动注册)
  4. 生成 Token 作为登录凭证
  5. 将用户信息存入 Redis Hash
  6. 返回 Token
java 复制代码
/**
 * 登录功能
 * @param loginForm 登录参数,包含手机号、验证码
 * @return 登录结果
 */
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1. 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误!");
    }

    // 2. 从 Redis 中获取验证码并校验
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
        return Result.fail("验证码错误");
    }

    // 3. 根据手机号查询用户
    User user = query().eq("phone", phone).one();

    // 4. 判断用户是否存在
    if (user == null) {
        // 不存在,创建新用户并保存到数据库
        user = createUserWithPhone(phone);
    }

    // 5. 保存用户信息到 Redis
    // 5.1 随机生成 Token,作为登录令牌
    String token = UUID.randomUUID().toString(true);

    // 5.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()));

    // 5.3 以 Hash 类型保存到 Redis
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);

    // 5.4 设置 Token 有效期
    // ⚠️ 潜在问题:到达时间后会被删除,理想状态应随用户活跃实时刷新
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

    // 6. 返回 Token
    return Result.ok(token);
}

2.3 登录校验

📌 配图:登录功能 Redis 存储流程图

修改后的校验逻辑:

  1. 从请求头中获取 Token
  2. 用 Token 去 Redis 查询用户信息(得到 Map)
  3. 将 Map 转换为 UserDTO 对象
  4. 存入 ThreadLocal
  5. 刷新 Token 有效期

2.4 Token 刷新优化

📌 配图:登录校验修改思路图

2.4.1 Token 刷新拦截器
java 复制代码
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;

import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;

/**
 * 拦截器:刷新 Token 有效期
 * 替换了在登录拦截器中进行获取和刷新的逻辑
 */
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");
        if (StrUtil.isBlank(token)) {
            response.setStatus(401);
            return false;
        }

        // 2. 基于 Token 获取 Redis 中的用户
        String key = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);

        // 3. 判断用户是否存在
        if (userMap.isEmpty()) {
            response.setStatus(401);
            return false;
        }

        // 4. 将查询到的 Hash 数据转为 UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        // 5. 保存用户信息到 ThreadLocal
        UserHolder.saveUser(userDTO);

        // 6. 刷新 Token 有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 7. 放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, 
                                Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}
2.4.2 简化后的登录拦截器

经过 Token 刷新拦截器处理后,登录拦截器只需判断 ThreadLocal 中是否有用户:

java 复制代码
import com.hmdp.dto.UserDTO;
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 {
        // 判断 ThreadLocal 中是否有用户
        if (UserHolder.getUser() == null) {
            // 没有,需要拦截,设置状态码 401
            response.setStatus(401);
            return false;
        }
        // 有用户,放行
        return true;
    }
}
2.4.3 拦截器配置(最终版)
java 复制代码
import com.hmdp.utils.LoginInterceptor;
import com.hmdp.utils.RefreshTokenInterceptor;
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 {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",   // 验证码
                        "/user/login"   // 登录
                ).order(1);

        // Token 刷新拦截器(放在登录拦截器之前,order 越小优先级越高)
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .excludePathPatterns(
                        "/user/code",   // 验证码
                        "/user/login"   // 登录
                )
                .addPathPatterns("/**")
                .order(0);
    }
}

总结

方案 优点 缺点 适用场景
Session 实现简单,自动管理 Cookie 集群下数据不共享,有内存泄漏风险 单机部署
Redis + Token 支持集群共享,可设置过期时间,性能高 需要手动管理 Token 刷新 分布式部署

关键优化点:

  1. 使用 Redis Hash 存储用户信息,便于字段级操作
  2. 使用 双拦截器(Token 刷新 + 登录校验),职责分离
  3. 使用 ThreadLocal 实现线程隔离,避免线程安全问题
  4. 使用 UserDTO 替代 User 实体,减少敏感信息传输和存储压力
相关推荐
芒鸽1 小时前
在仓颉语言里造一个没有反射的服务端框架
开发语言·华为·harmonyos
CodeStats1 小时前
《源纹天书》第121-125章:源匠归来——全栈重构与归元圣域的2.0时代
java·开发语言·源纹天书
binbin_521 小时前
UIAbility 与 WindowStage:窗口创建、加载、销毁的完整链路
开发语言·javascript·深度学习·华为·harmonyos
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第154题】【06_Spring篇】第14题:Spring 支持的 Bean 作用域
java·开发语言·spring·面试
wuminyu2 小时前
markword在高并发场景下变化剖析
java·linux·c语言·jvm·c++
旖-旎2 小时前
QT界面优化(6)
开发语言·c++·qt
AI科技星2 小时前
基于超复数广义分形流形的电磁耦合与缪子反常磁矩几何理论
开发语言·平面·重构·概率论·量子计算·乖乖数学·全域数学
组合缺一2 小时前
用 ChatModel 构建 LLM 驱动的 Java 应用
java·开发语言·ai·llm·solon·rag
zzz_23682 小时前
【Java实习面试算法冲刺】哈希!
java·算法·面试