【黑马点评 | 第一篇】从 Session 到 Redis+Token 的改造与拦截器实现
一、短信登录要解决什么问题
这次实现的功能是短信登录,它的具体代码需求有:
- 输入手机号并发送短信验证码
- 校验手机号和短信验证码
- 用户不存在时自动注册
- 登录成功后保存用户登录状态
- 拦截未登录请求并返回 401
- 在登录状态有效期内自动刷新凭证
黑马点评项目先使用 Session 实现,再改造成 Redis+Token。前者适合单机环境下理解流程,后者解决多实例部署和多端请求的问题。
二、Session 方案实现短信登录
2.1 发送验证码
Controller 只负责接收请求参数并转交 Service:
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
return userService.sendCode(phone, session);
}
Service 中先校验手机号,再生成 6 位数字验证码,最后放入 Session:
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);
// 6.返回 OK
return Result.ok();
手机号是请求输入,Session 是服务端按会话保存验证码的位置。登录请求到达后,后端用同一个 Session 取出验证码并进行比较。
2.2 验证码登录与自动注册
登录接口接收手机号和验证码:
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
return userService.login(loginForm, session);
}
Service 的核心流程是“校验验证码、查用户、没有就创建、保存登录用户”:
// 1.校验验证码
Object cacheCode = session.getAttribute("code");
if (cacheCode == null || !cacheCode.toString().equals(loginForm.getCode())) {
// 2.验证码不一致,报错
return Result.fail("验证码错误");
}
// 3.一致,根据手机号查询用户
User user = query().eq("phone", loginForm.getPhone()).one();
// 4.判断用户是否存在
if (user == null) {
// 5.不存在,则创建
user = createUserWithPhone(loginForm.getPhone());
}
// 6.保存用户信息到 Session
session.setAttribute("user", BeanUtils.copyProperties(user, UserDTO.class));
return Result.ok();
第六段功能:手机号查询依赖数据库中的唯一用户记录。用户不存在时自动创建,昵称由系统前缀和随机字符串组成。
Session 中保存 UserDTO,可以避免把实体中的非登录字段直接暴露给后续业务。比如手机号,密码等,用DTO保存在互联网上的变量更少更安全.
2.3 单拦截器校验登录状态
Session 方案通过 HandlerInterceptor 在 Controller 执行前检查登录状态。拦截器从会话中读取用户:
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 1.获取 Session
HttpSession session = request.getSession();
// 2.获取 Session 中的用户
Object user = session.getAttribute("user");
if (user == null) {
// 3.不存在,拦截,返回 401 状态码
response.setStatus(401);
return false;
}
// 4.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser((UserDTO) user);
// 5.放行
return true;
}
仅有拦截器类还不够,还需要在 Spring MVC 配置类中注册它,并明确哪些接口允许未登录访问:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
addInterceptor 注册登录拦截器,未配置排除路径的请求都会进入 preHandle。/user/code 和 /user/login 是登录前必须访问的接口,必须放行;/shop/**、/voucher/**、/shop-type/**、/upload/** 和 /blog/hot 属于公开访问接口,也不要求用户先登录。其他路径默认需要登录。
这两部分要连起来理解:LoginInterceptor 负责判断“当前请求有没有登录用户”,MvcConfig 负责规定“哪些请求需要进行这个判断”。请求进入受保护路径时,拦截器从 Session 取出用户并写入 UserHolder,后续业务代码就能通过 UserHolder 获取当前用户;请求完成后应调用 remove() 清理 ThreadLocal,避免线程复用时残留上一次请求的用户信息。**
三、为什么从 Session 改成 Redis+Token
Session 默认保存在单个 Tomcat 实例内存中。用户第一次请求落到实例 A,第二次请求落到实例 B 时,B 找不到 A 创建的 Session,就会把已登录用户当成未登录用户。
Session 共享可以解决这个问题,但需要额外同步会话数据。Redis 本身就是多实例可访问的集中式存储,因此项目把验证码和登录用户状态放入 Redis,再把一个 Token 返回给前端。浏览器、App 和小程序都可以在请求头中主动携带 Token。
四、Redis+Token 方案改造
4.1 Redis 版发送验证码
// 1.生成验证码
String code = RandomUtil.randomNumbers(6);
// 2.保存验证码到 Redis
stringRedisTemplate.opsForValue().set(
LOGIN_CODE_KEY + phone,
code,
LOGIN_CODE_TTL,
TimeUnit.MINUTES
);
验证码由 StringRedisTemplate 写入 Redis,Key 使用 login:code:{phone}。手机号保证不同用户的验证码互不覆盖,TTL 负责自动删除过期验证码。当前项目常量集中定义在 RedisConstants 中,便于统一修改 Key 前缀和有效期。
4.2 Redis 版登录与自动注册
登录时按手机号读取 Redis 验证码:
// 1.从 Redis 中获取验证码
String rightCode = stringRedisTemplate.opsForValue()
.get(LOGIN_CODE_KEY + phone);
if (rightCode == null || !rightCode.equals(loginForm.getCode())) {
// 2.验证码不一致,返回错误
return Result.fail("验证码不正确");
}
验证码正确后,数据库查询和自动注册仍然由 MyBatis-Plus 完成。登录成功后生成 UUID Token,把用户 DTO 转成 Map 保存到 Redis Hash,并设置过期时间:
// 1.生成 Token
String token = UUID.randomUUID().toString(true);
// 2.将 UserDTO 转换为 Hash
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, value) ->
value == null ? "" : value.toString()));
// 3.保存用户信息到 Redis
String key = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(key, userMap);
// 4.设置过期时间
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.SECONDS);
// 5.返回 Token
return Result.ok(token);
前端保存返回的 Token,后续请求通过 Authorization 请求头携带。后端不再依赖 JSESSIONID,而是用 Token 找到 Redis 中的用户信息。
五、Redis Key-Value:String 与 Hash 的差异
5.1 String:保存单个值
String 的结构是一个 Key 对应一个 Value,适合验证码、计数器和简单状态。短信验证码只需要保存一段数字,因此使用 opsForValue():
login:code:13800000000 -> 123456
5.2 Hash:保存对象字段
Hash 的结构是一个 Key 下包含多个 Field-Value,适合用户这种有多个属性的对象:
login:token:xxx
id -> 1
nickName -> user_xxx
icon -> avatar.png
项目使用 opsForHash().putAll 保存 UserDTO 转换后的字段。读取时可以一次获得多个字段,再通过 BeanUtil 组装回 UserDTO。
5.3 两种结构的选择
| 对比项 | String | Hash |
| 数据结构 | Key-Value | Key-Field-Value |
| 适合数据 | 单个简单值 | 多字段对象 |
| 当前用途 | 短信验证码 | 登录用户信息 |
| Java API | opsForValue() | opsForHash() |
| 过期对象 | 整个 Key | 整个 Hash Key |
选择 Redis 类型时,先看业务数据是一个值还是一组字段,再决定存储粒度。验证码不需要拆字段,用户信息则不适合拼成难以局部读取的单一值。
六、MyBatis-Plus 在登录功能中的应用
6.1 ServiceImpl 提供通用能力
当前 UserServiceImpl 继承:
public class UserServiceImpl
extends ServiceImpl<UserMapper, User>
implements IUserService {
}
第一个泛型是 Mapper 类型,第二个是实体类型。继承后可以直接使用 query()、getById()、save() 等通用方法,Service 不需要为基础 CRUD 重复编写 Mapper 实现。
6.2 链式条件查询
User user = query()
.eq("phone", phone)
.one();
query() 创建查询入口,eq 添加手机号等值条件,one 表示期望得到一条记录。这个调用对应“按手机号查询用户”的业务意图,手机号字段应在数据库层保证唯一。
6.3 自动注册用户
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX
+ RandomUtil.randomString(6));
save(user);
save 使用当前 Service 绑定的 UserMapper 完成落库。登录流程因此形成了“手机号查询,不存在就创建”的闭环。
6.4 分页插件配置
项目通过配置类注册 MyBatis-Plus 分页插件:
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(
new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
分页插件不是短信登录必需组件,但它展示了 MyBatis-Plus 的统一插件配置方式。官方文档要求在 MybatisPlusInterceptor 中添加分页拦截器,并指定数据库类型。
七、单拦截器与双拦截器
7.1 单拦截器的职责
Redis 方案的单拦截器可以完成三件事:读取请求头 Token、查询 Redis 用户、判断是否登录。查到用户后写入 UserHolder,查不到则返回 401。
这种实现适合先跑通登录校验,但如果只给受保护路径注册拦截器,用户访问公开接口时不会触发 Token 续期。
7.2 RefreshTokenInterceptor
刷新拦截器注册到 /**,先尝试从 Token 恢复用户:
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(LOGIN_USER_KEY + token);
if (userMap.isEmpty()) {
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(
userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(
LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.SECONDS);
return true;
Token 无效时继续放行,由后续登录拦截器决定当前接口是否必须登录。Token 有效时刷新 TTL,实现滑动续期。afterCompletion 中必须清理 UserHolder,避免线程复用造成用户信息串请求。
7.3 LoginInterceptor
登录拦截器只判断 UserHolder 是否有用户:
if (UserHolder.getUser() == null) {
response.setStatus(401);
return false;
}
return true;
它只注册到需要登录的路径,并设置为第二个执行。这样两个拦截器的职责分别是“尽量恢复用户并续期”和“对受保护接口强制认证”。
7.4 注册顺序
registry.addInterceptor(
new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**")
.order(0);
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns("/user/code", "/user/login")
.order(1);
先执行刷新拦截器,后执行登录拦截器,登录拦截器才能读取前者写入 ThreadLocal 的用户。
八、功能验证
按以下顺序验证即可:发送验证码、提交手机号和验证码登录、检查 Token、携带 Token 访问 /user/me,再分别测试无 Token 和 Token 过期时访问受保护接口的结果。
九、总结
Session 方案帮助理解短信登录的基本流程,Redis+Token 方案负责解决多实例和多端访问。验证码使用 String,用户信息使用 Hash;MyBatis-Plus 负责用户查询与保存;双拦截器将用户恢复、Token 续期和强制登录校验拆开处理。
更多推荐


所有评论(0)