实战篇 - 短信登录模块
📋 模块概览
本模块实现”黑马点评”项目的短信登录功能,分四个阶段递进:
| 阶段 | 内容 | 核心技术 |
|---|---|---|
| 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 设计三大原则:
- 唯一性:确保不同数据不会互相覆盖
- 便于携带:客户端能方便地携带 key 来获取数据(如手机号、Token)
- 设置有效期:避免数据无限增长,占用内存
六、核心知识点总结
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 常见踩坑点
- Redis 序列化:
StringRedisTemplate要求所有 value 为 String,对象转 Map 时 Long 等类型需转 String - Key 前缀:Redis 是共享空间,所有业务 key 必须加业务前缀避免冲突
- 必须设有效期:不设有效期 → 数据无限增长 → Redis 内存溢出
- 拦截器顺序:RefreshToken 必须在 LoginInterceptor 之前执行
- 拦截器注入:手动 new 的拦截器不能用
@Resource注入,需通过构造函数传入
七、完整请求流程图
用户发起请求
│
▼
RefreshTokenInterceptor(所有请求)
├─ 获取请求头 authorization → Token
├─ Token 为空?→ 放行
├─ Redis 查询用户 → 为空?→ 放行
├─ 用户存入 ThreadLocal
├─ 刷新 Token 有效期(30分钟)
└─ 放行
│
▼
LoginInterceptor(需要登录的路径)
├─ ThreadLocal 有用户?→ 放行
└─ 无用户 → 401 拦截
│
▼
Controller 业务逻辑
├─ 从 UserHolder.getUser() 获取当前用户
└─ 执行业务...