黑马点评 - 短信验证码登录实现
目录
- [基于 Session 的实现](#基于 Session 的实现)
- [1.1 验证码存储](#1.1 验证码存储)
- [1.2 登录与注册](#1.2 登录与注册)
- [1.3 登录拦截](#1.3 登录拦截)
- [1.4 集群 Session 共享问题](#1.4 集群 Session 共享问题)
- [基于 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 短信登录
核心流程:
- 校验手机号格式
- 从 Redis 获取验证码并校验
- 根据手机号查询用户(不存在则自动注册)
- 生成 Token 作为登录凭证
- 将用户信息存入 Redis Hash
- 返回 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 存储流程图
修改后的校验逻辑:
- 从请求头中获取 Token
- 用 Token 去 Redis 查询用户信息(得到 Map)
- 将 Map 转换为
UserDTO对象 - 存入
ThreadLocal - 刷新 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 刷新 | 分布式部署 |
关键优化点:
- 使用 Redis Hash 存储用户信息,便于字段级操作
- 使用 双拦截器(Token 刷新 + 登录校验),职责分离
- 使用 ThreadLocal 实现线程隔离,避免线程安全问题
- 使用 UserDTO 替代 User 实体,减少敏感信息传输和存储压力




