|

实战篇 - 短信登录模块

📋 模块概览

本模块实现”黑马点评”项目的短信登录功能,分四个阶段递进:

阶段 内容 核心技术
1 项目导入与环境搭建 Spring Boot + MyBatisPlus
2 基于 Session 实现短信登录 Session + Cookie
3 Session 共享问题分析 集群部署问题
4 基于 Redis 实现短信登录 Redis + 拦截器

一、项目架构

1.1 核心数据表

表名 用途
tb_user / tb_user_info 用户基本信息 + 详情信息
tb_shop / tb_shop_type 商户信息 / 商户类型
tb_blog 达人探店日记
tb_follow 关注关系
tb_voucher / tb_voucher_order 优惠券 / 优惠券订单

1.2 项目架构

前端 (Nginx)          后端 (Tomcat)
    |                      |
  8080 端口  ─── Ajax ──→  8081 端口
  手机模式访问              MySQL / Redis
  • 前后端分离:前端部署在 Nginx,后端部署在 Tomcat
  • 单体架构:不采用微服务,专注学习 Redis
  • 可水平扩展:支持 Tomcat 集群 + 负载均衡

1.3 关键配置

# application.yaml 关键配置
server:
  port: 8081
  name: hmdp

spring:
  datasource:
    url: jdbc:mysql://localhost:3307/hmdp  # 需修改
    username: root
    password: 123
  redis:
    host: 192.168.150.101  # 需修改为你的 Redis 地址
    port: 6379
    password: 123321

⚠️ 注意事项:MySQL 版本必须 5.7+,导入前务必修改 application.yaml 中的 Redis 和 MySQL 地址。


二、基于 Session 的短信登录

2.1 业务流程(三步走)

流程一:发送短信验证码

用户提交手机号 → 校验手机号格式 → 生成6位随机验证码
    → 保存到 Session → 发送短信(模拟)

关键代码

// UserController
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, 
                       HttpSession session) {
    return userService.sendCode(phone, session);
}

// UserServiceImpl - sendCode
public Result sendCode(String phone, HttpSession session) {
    // 1. 校验手机号格式
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误!");
    }
    // 2. 生成6位随机验证码
    String code = RandomUtil.randomNumbers(6);
    // 3. 保存到 Session
    session.setAttribute("code", code);
    // 4. 发送短信(模拟)
    log.debug("发送短信验证码成功,验证码:{}", code);
    return Result.ok();
}

💡 工具类说明:手机号正则校验用 RegexUtils.isPhoneInvalid(),验证码生成用 Hutool 的 RandomUtil.randomNumbers(6)

流程二:短信验证码登录与注册

用户提交手机号+验证码 → 校验手机号 → 校验验证码
    → 查询用户 → [存在] 登录 / [不存在] 注册 → 保存到 Session

关键代码

// UserServiceImpl - login
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1. 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误!");
    }
    // 2. 校验验证码(从 Session 取)
    Object cacheCode = session.getAttribute("code");
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.toString().equals(code)) {
        return Result.fail("验证码错误");
    }
    // 3. 根据手机号查询用户
    User user = query().eq("phone", phone).one();  // MyBatisPlus
    // 4. 判断用户是否存在
    if (user == null) {
        user = createUserWithPhone(phone);  // 新用户注册
    }
    // 5. 保存用户到 Session
    session.setAttribute("user", user);
    return Result.ok();
}

// 创建新用户
private User createUserWithPhone(String phone) {
    User user = new User();
    user.setPhone(phone);
    user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX 
                     + RandomUtil.randomString(10));
    save(user);  // MyBatisPlus 的 save
    return user;
}

💡 MyBatisPlus 技巧query().eq("phone", phone).one() 等价于 SELECT * FROM tb_user WHERE phone = ?,无需手写 SQL。

流程三:登录状态校验

用户请求携带 Cookie → 获取 Session ID → 获取 Session → 获取用户
    → [有用户] 放行 / [无用户] 拦截

关键代码

// LoginInterceptor - 前置拦截
public boolean preHandle(HttpServletRequest request, 
                         HttpServletResponse response, 
                         Object handler) {
    // 1. 获取 Session
    HttpSession session = request.getSession();
    // 2. 获取用户
    Object user = session.getAttribute("user");
    // 3. 判断用户是否存在
    if (user == null) {
        response.setStatus(401);
        return false;  // 拦截
    }
    // 4. 存入 ThreadLocal,放行业务使用
    UserHolder.saveUser((UserDTO) user);
    return true;  // 放行
}

// LoginInterceptor - 后置清理
public void afterCompletion(..., Exception ex) {
    UserHolder.removeUser();  // 避免内存泄漏
}

2.2 隐藏敏感信息

问题:Session 中存储完整的 User 对象,可能包含密码等敏感信息。

解决方案:使用 UserDTO 只保留必要字段。

// UserDTO 只保留 ID、昵称、头像
@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

// 保存前转换
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
session.setAttribute("user", userDTO);

💡 BeanUtil.copyProperties(source, targetClass) 是 Hutool 提供的对象属性拷贝工具。


三、Session 共享问题

3.1 问题描述

当 Tomcat 做水平扩展(集群部署)时:

                    ┌─→ Tomcat1 (有自己的 Session)
用户 → Nginx(负载均衡) ─┤
                    └─→ Tomcat2 (有自己的 Session)
  • 用户第一次请求 → Tomcat1 → 保存 Session
  • 用户第二次请求 → Tomcat2 → Session 空!登录丢失

3.2 为什么不用 Session 拷贝?

缺点 说明
内存浪费 多台 Tomcat 保存相同数据
数据不一致 拷贝有延迟,期间访问会出错

3.3 解决方案:用 Redis 替代 Session

替代方案需满足三个条件:

条件 Session Redis
数据共享 ❌ 每台独立 ✅ 统一存储
内存存储 ✅ 微秒级性能
Key-Value 结构 ✅ 内置支持

四、基于 Redis 实现短信登录

4.1 改造点分析

4.1.1 发送验证码(小改)

对比项 Session 版 Redis 版
存储位置 session.setAttribute("code", code) stringRedisTemplate.opsForValue().set(key, code)
Key 设计 固定 "code"(Session 隔离) 手机号(Redis 共享空间,需唯一)
有效期 Session 默认30分钟 必须设置(如2分钟),避免无限增长
// Redis 版发送验证码
stringRedisTemplate.opsForValue().set(
    LOGIN_CODE_KEY + phone,       // key: "login:code:" + 手机号
    code,
    LOGIN_CODE_TTL, TimeUnit.MINUTES  // 2分钟有效期
);

Key 设计原则: 1. ✅ 唯一性:用手机号作 key,每个手机号验证码互不覆盖 2. ✅ 便于后续获取:用户登录时刚好提交手机号,可直接用它取验证码

常量定义RedisConstants.java):

public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;  // 分钟

4.1.2 登录注册(大改)

这是改造最复杂的地方,流程变化较大:

之前 (Session):                    现在 (Redis):
用户提交 → 校验 → 保存 Session      用户提交 → 校验 → 生成随机 Token
    ↓                                   ↓
返回 (Cookie自动管理)                 以 Token 为 Key 存入 Redis
                                      ↓
                                  返回 Token 给前端

核心变化

对比项 Session 版 Redis 版
用户存储 session.setAttribute("user", user) Hash 结构存入 Redis
登录凭证 Session ID(Cookie 自动管理) 自定义 Token(需手动返回)
数据类型 User 对象 Hash(字段独立,可单字段 CRUD)

为什么用 Hash 而非 String?

特性 String(JSON) Hash
单字段修改 需整体替换 ✅ 直接修改
内存占用 包含 JSON 符号({}、““) 只存数据本身
灵活性

关键代码

// 1. 生成随机 Token 作为登录凭证
String token = UUID.randomUUID().toString(true);  // 不带横线

// 2. 将 UserDTO 转为 Hash (Map) 存储
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())  // Long → String,解决序列化问题
);

// 3. 存入 Redis(Hash 结构)
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);

// 4. 设置有效期(30分钟)
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

// 5. 返回 Token 给前端
return Result.ok(token);

⚠️ 序列化坑StringRedisTemplate 要求 key 和 value 都是 String。使用 BeanUtil.beanToMap 时,UserDTO.id 是 Long 类型会报类型转换异常。解决方案:用 setFieldValueEditor 将所有值转为 String。

前端配合

// 登录后保存 Token
// login.js
sessionStorage.setItem("user", token);

// 每次请求携带 Token
// common.js - axios 拦截器
axios.interceptors.request.use(config => {
    const token = sessionStorage.getItem("user");
    if (token) {
        config.headers['authorization'] = token;
    }
    return config;
});

4.1.3 登录状态校验(拦截器改造)

Session 版(单一拦截器)

请求 → LoginInterceptor 拦截所有需要登录的路径
    → Cookie → Session → 用户 → ThreadLocal → 放行

Redis 版(双拦截器)

请求 → RefreshTokenInterceptor(拦截一切路径)
    → 获取 Token → Redis 查询用户 → ThreadLocal + 刷新有效期 → 放行
    → LoginInterceptor(只拦截需要登录的路径)
        → 检查 ThreadLocal 是否有用户 → 有则放行,无则拦截

为什么要两个拦截器?

问题:如果只用一个拦截器拦截”需要登录的路径”,用户访问首页、店铺详情等不需要登录的页面时,Token 有效期不会刷新,30分钟后即使用户一直在浏览也会掉线。

RefreshTokenInterceptor(拦截一切路径):

public class RefreshTokenInterceptor implements HandlerInterceptor {
    
    private StringRedisTemplate stringRedisTemplate;  // 构造函数注入

    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, Object handler) {
        // 1. 获取请求头中的 Token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;  // 无 Token 也放行(可能是未登录用户)
        }
        // 2. 基于 Token 从 Redis 获取用户
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
            .entries(RedisConstants.LOGIN_USER_KEY + token);
        // 3. 判断用户是否存在
        if (userMap.isEmpty()) {
            return true;  // 不存在也放行
        }
        // 4. Hash 转 UserDTO,存入 ThreadLocal
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, 
            new UserDTO(), false);
        UserHolder.saveUser(userDTO);
        // 5. 刷新 Token 有效期 ⭐
        stringRedisTemplate.expire(
            RedisConstants.LOGIN_USER_KEY + token,
            RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;
    }

    public void afterCompletion(...) {
        UserHolder.removeUser();  // 清理 ThreadLocal
    }
}

LoginInterceptor(只拦截需登录的路径):

public class LoginInterceptor implements HandlerInterceptor {
    
    public boolean preHandle(..., Object handler) {
        // 判断 ThreadLocal 中是否有用户
        if (UserHolder.getUser() == null) {
            response.setStatus(401);  // 未登录
            return false;
        }
        return true;
    }
}

拦截器配置MvcConfig.java):

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 刷新 Token 拦截器(拦截一切,先执行)
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
            .addPathPatterns("/**")
            .order(0);  // 优先级高

        // 登录拦截器(拦截需要登录的路径,后执行)
        registry.addInterceptor(new LoginInterceptor())
            .excludePathPatterns(
                "/user/code",      // 发送验证码
                "/user/login",     // 登录
                "/blog/hot",       // 热门博客
                "/shop/**",        // 店铺查询
                "/shop-type/**",   // 店铺类型
                "/upload/**",      // 上传
                "/voucher/**"      // 优惠券查询
            ).order(1);
    }
}

💡 拦截器执行顺序order(0) 先执行,order(1) 后执行。确保 RefreshToken 先于 LoginInterceptor 执行。


五、Redis Key 设计规范总结

业务 Key 格式 数据类型 有效期
验证码 login:code:{phone} String 2分钟
登录用户 login:token:{token} Hash 30分钟(滚动刷新)

Key 设计三大原则

  1. 唯一性:确保不同数据不会互相覆盖
  2. 便于携带:客户端能方便地携带 key 来获取数据(如手机号、Token)
  3. 设置有效期:避免数据无限增长,占用内存

六、核心知识点总结

6.1 Session vs Redis 登录对比

维度 Session Redis
存储位置 Tomcat 内存 独立 Redis 服务
集群共享 ❌ 不支持 ✅ 天然支持
登录凭证 Cookie 中的 Session ID(自动管理) 自定义 Token(手动管理)
用户存储 直接存对象 Hash 结构(字段独立)
有效期管理 Session 默认30分钟 需手动设置 + 滚动刷新
数据安全 服务器端,较安全 Token 暴露在前端,需避免敏感信息

6.2 编码技巧

  • 反向判断避免嵌套:验证码不一致时直接 return Result.fail(),避免 if-else 嵌套过深
  • ThreadLocal 传参:拦截器 → Controller 之间传递用户信息
  • BeanUtil 便捷转换copyProperties(对象拷贝)、beanToMap(对象转 Map)、fillBeanWithMap(Map 填充对象)
  • Hutool 百宝箱RandomUtil(随机数)、RegexUtils(正则校验)、StrUtil(字符串工具)
  • Spring Bean 注入技巧:手动 new 的对象(如 Interceptor)无法用 @Autowired,需通过构造函数从 Spring 管理的 Config 类注入

6.3 常见踩坑点

  1. Redis 序列化StringRedisTemplate 要求所有 value 为 String,对象转 Map 时 Long 等类型需转 String
  2. Key 前缀:Redis 是共享空间,所有业务 key 必须加业务前缀避免冲突
  3. 必须设有效期:不设有效期 → 数据无限增长 → Redis 内存溢出
  4. 拦截器顺序:RefreshToken 必须在 LoginInterceptor 之前执行
  5. 拦截器注入:手动 new 的拦截器不能用 @Resource 注入,需通过构造函数传入

七、完整请求流程图

用户发起请求
    │
    ▼
RefreshTokenInterceptor(所有请求)
    ├─ 获取请求头 authorization → Token
    ├─ Token 为空?→ 放行
    ├─ Redis 查询用户 → 为空?→ 放行
    ├─ 用户存入 ThreadLocal
    ├─ 刷新 Token 有效期(30分钟)
    └─ 放行
    │
    ▼
LoginInterceptor(需要登录的路径)
    ├─ ThreadLocal 有用户?→ 放行
    └─ 无用户 → 401 拦截
    │
    ▼
Controller 业务逻辑
    ├─ 从 UserHolder.getUser() 获取当前用户
    └─ 执行业务...
评论交流

文章目录