【黑马点评日记02】:Session+ThreadLocal实现短信登录

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介:java后端学习者

❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb

命运的结局尽可永在,不屈的挑战却不可须臾或缺!

前言:

前一天我们导入了黑马点评项目,并配置好了环境,那么从今天开始,我们就要开始完成项目的相关业务操作了,今天实现的是短信登录功能。

摘要:

本文介绍了基于Session和ThreadLocal的短信验证码登录实现方案。主要内容包括:1. 验证码登录流程分为发送验证码和登录注册两个阶段,验证码和用户信息存储在Session中;2. 使用ThreadLocal作为临时用户信息传递工具,避免代码臃肿;3. 详细阐述了Session存储验证码和用户信息的必要性及优势;4. 提供了完整的代码实现示例,包括发送验证码、登录注册接口及拦截器实现;5. 强调了使用UserDTO而非User实体类来保护敏感信息。该方案利用Session维持登录状态,通过ThreadLocal实现用户信息在请求各层间的优雅传递


关于Session和ThreadLocal

基于Session的短信验证码登录流程

主要分为两个阶段:发送验证码登录注册


1. 发送验证码流程
  • 前端:用户输入手机号,点击"获取验证码"。

  • 后端

    1. 接收手机号,进行基本校验(非空、格式)。

    2. 生成一个随机6位数字验证码(如 123456)。

    3. 关键步骤:将验证码存入 HttpSession。

      • Key:一个固定字符串或与手机号关联的Key,例如 "code" 或 "login:code:"+phone。

      • Value:验证码本身。

    4. 调用短信服务商API发送验证码(生产环境)。在测试/教学阶段,通常直接打印到控制台,或模拟发送。

    5. 返回成功提示。


2. 登录注册流程
  • 前端:用户输入手机号 + 收到的验证码,点击登录。

  • 后端

    1. 接收手机号和验证码。

    2. 从Session中获取之前存储的验证码(根据相同的Key)。

    3. 校验:判断用户提交的验证码 是否等于 Session中存储的验证码,并且验证码是否过期(Session有超时时间)。

      • 不一致或不存在:返回"验证码错误"。

      • 一致:继续。

    4. 查询用户:根据手机号查询数据库。

    5. 分支处理:

      • 已存在用户:直接获取用户信息。

      • 不存在用户(注册):创建新用户,填充手机号、默认昵称(如user_+随机数)、默认头像等,保存到数据库,然后获取这个新用户。

    6. 登录态保持:将查询到的用户对象(或用户信息,如ID、昵称、头像)存入 Session。

      • Key:例如 "user"。

      • Value:用户对象。

    7. 返回成功(通常前端会跳转到首页)。

3.校验登陆状态:
复制代码
【用户请求】GET /user/order
    ↓
① 请求进入 Tomcat(携带 Cookie: JSESSIONID=ABC123)
    ↓
② Spring MVC 查找对应的拦截器
    ↓
③ 拦截器 preHandle 方法执行
    ↓
④ 从 Session 获取用户信息
    ↓
⑤ 判断是否已登录
    ↓
┌─────────────────┴─────────────────┐
↓                                   ↓
【已登录】                          【未登录】
↓                                   ↓
存入 ThreadLocal                    返回 401 状态码
↓                                   ↓
放行到 Controller                   拦截,不进入 Controller
↓                                   ↓
执行业务逻辑                         响应返回前端
↓
返回数据给前端

关键区分:存储位置 vs. 传递方式
概念 存储位置 生命周期 作用
Session 存储用户 服务器内存(Tomcat) 会话级别(30分钟) 跨请求共享用户信息
ThreadLocal 存储用户 当前线程的内存 单次请求级别(请求结束即清理) 在当前请求的各个方法间传递用户信息

ThreadLocal 不是替代 Session 的存储方案,而是 Session 的"临时搬运工"。


完整流程解析

复制代码
【请求到达】携带 Cookie: JSESSIONID=ABC123
    ↓
【Tomcat 根据 JSESSIONID 找到 Session】
    Session 内容: {user: User对象(张三)}
    ↓
【拦截器 preHandle】
    session.getAttribute("user") → 拿到 User(张三)
    UserHolder.setUser(张三)  ← 存入 ThreadLocal
    ↓
【Controller 方法】
    UserHolder.getUser() → 拿到 User(张三)  ← 从 ThreadLocal 取
    处理业务...
    ↓
【Service 层】
    UserHolder.getUser() → 仍然拿到 User(张三)  ← 同一个线程
    处理业务...
    ↓
【拦截器 afterCompletion】
    UserHolder.remove()  ← 清理 ThreadLocal,防止内存泄漏
    ↓
【响应返回给客户端】

关键点:

  1. 用户信息的主存储位置是 Session(服务器内存)

  2. ThreadLocal 只是当前请求的"临时缓存",方便各层代码获取用户

  3. 请求结束后,ThreadLocal 会被清理,但 Session 中的用户还在

方案 代码示例 问题
不用 ThreadLocal Controller: session.getAttribute("user") Service: 需要传入 user 参数 Mapper: 需要继续传 参数到处传递,代码臃肿
用 ThreadLocal 任何地方直接 UserHolder.getUser() 优雅、解耦

核心问题:为什么要把验证码和用户放Session里

这是基于 HTTP协议的无状态性Session的服务端存储机制 决定的。

1. 为什么验证码放Session里

核心目的:将"服务端发出的验证码"与"当前用户的浏览器会话"绑定,防止跨请求伪造和盗用。

  • 身份绑定 :你给手机号 138****0000 发了验证码 123456。这个验证码只应该属于这个用户的这次请求。Session天然与一个特定用户(通过Cookie中的JSESSIONID标识)绑定。当用户提交验证码时,服务端从该用户的Session中取出验证码进行比对,确保了 "这个验证码确实是发给这个浏览器的"

  • 避免全局冲突 :如果把验证码放Redis(内存数据库)或内存Map中,并且只用手机号做Key,会出现问题:用户A的手机App和电脑网页同时登录,后一次请求会覆盖前一次的验证码。而Session隔离了不同会话(即使是同一手机号、不同浏览器,也是不同Session)。

  • 自动过期:Session本身有超时时间(如30分钟),验证码存在Session里会随着Session过期而自动失效,无需额外编写过期删除逻辑。验证码的安全生命周期自然得到保证。

  • 安全性:验证码是敏感信息,绝不应该放在前端(Cookie、LocalStorage)或URL参数中让客户端保存。放在服务端Session是最安全的方式之一,客户端根本无法获取到验证码明文。

2. 为什么用户信息放Session里

核心目的:实现登录态跟踪,避免每次请求都查询数据库。

  • 证明"已登录" :用户登录后,后续请求(如查询订单、点赞)如何知道是谁发起的?服务端如何知道这个请求是"已登录"用户?------通过Session中是否存在用户对象来判断。存在且有效,即视为已登录。

  • 减少DB查询:用户登录后,每个请求可能都需要获取用户信息(如展示昵称、头像、权限校验)。如果每次都从数据库查,在高并发下压力很大。把用户信息放入Session(即服务端内存),后续请求直接从Session中快速取出,性能极高。

  • 状态维持 :HTTP是无状态的**,Session机制在服务端维持了状态。用户信息放入Session,等同于在服务端标记了"这个会话对应的用户是张三"。后续请求带着JSESSIONID来,服务端就知道是张三。**

  • 安全控制:可以方便地实现踢人下线、强制过期等管理功能。例如,管理员注销某个用户时,可以找到并销毁其Session。


代码实现:短信验证码的发送

接口设计:

项目 内容
接口名称 发送短信验证码
请求方式 POST
请求路径 /user/code
请求参数 phone(手机号)
响应格式 JSON
核心逻辑 校验手机号 → 生成验证码 → 存入Session → 发送短信(模拟)

Controller

java 复制代码
/**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 发送短信验证码并保存验证码

        return userService.sendCode(phone,session);
    }

Service

实现Controller没有的方法,然后在这里实现具体的业务操作

java 复制代码
/**
     * 发送验证码
     * @param phone
     * @param session
     * @return
     */
    public Result sendCode(String phone, HttpSession session) {
        //校验手机号
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号格式错误");
        }
        //生成验证码
        String code = RandomUtil.randomNumbers(6);
        //保存验证码
        session.setAttribute("code",code);
        //发送验证码(模拟)
        System.out.println("发送验证码成功:"+code);
        return Result.ok();
    }

代码实现:登录和注册

接口设计:

项目 内容
接口名称 短信验证码登录/注册
请求方式 POST
请求路径 /user/login
请求参数 phone(手机号)、code(验证码)
响应格式 JSON
核心逻辑 校验验证码 → 查询用户 → 不存在则注册 → 存入Session

Controller:

java 复制代码
  /**
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        // 实现登录功能
        return userService.login(loginForm,session);
    }

Service:

java 复制代码
 /**
     * 登录和注册功能
     * @param loginForm
     * @param session
     * @return
     */
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //校验手机号
        if(RegexUtils.isPhoneInvalid(loginForm.getPhone())){
            return Result.fail("手机号格式错误");
        }
        //校验验证码
        String cacheCode = (String)session.getAttribute("code");
        String code= loginForm.getCode();
        if(cacheCode==null||!cacheCode.equals(code)){
            return Result.fail("验证码错误");
        }
        //查询用户
        User user = query().eq("phone", loginForm.getPhone()).one();
        //判断用户是否存在
        if(user==null){
            //不存在,创建新用户并保存
           user= createUserWithPhone(loginForm.getPhone());
        }
        //保存用户
        session.setAttribute("user",user);


        return null;
    }

    private User createUserWithPhone(String phone) {
        //创建用户
        User user=new User();
        user.setPhone(phone);
        user.setNickName(RandomUtil.randomString(10));
        //保存用户
        save(user);
        return  user;
    }

Mybatis-Plus

关于这里保存新用户到数据库中的方法,我们用的是Mybatis-Plus,

复制代码
query().eq("phone", loginForm.getPhone()).one();
复制代码
save(user);

Mybatis-Plus:

对比 传统 MyBatis MyBatis-Plus
插入数据 写 XML 或注解 SQL 直接调用 save(user)
查询数据 SELECT * FROM user WHERE id = ? 调用 selectById(id)
更新数据 UPDATE user SET ... 调用 updateById(user)
删除数据 DELETE FROM user WHERE ... 调用 deleteById(id)
  1. save(user) 是什么意思?
sql 复制代码
java

save(user);  // 把 user 对象插入到数据库

等价于这条 SQL:

sql 复制代码
sql

INSERT INTO user (phone, nick_name, create_time, update_time) 
VALUES ('13800138000', 'user_abc123', '2024-01-15 10:30:00', '2024-01-15 10:30:00')
  1. 完整代码示例
java 复制代码
java

// 你的代码
private User createUserWithPhone(String phone) {
    User user = new User();
    user.setPhone(phone);
    user.setNickName("user_" + RandomUtil.randomString(8));
    user.setCreateTime(LocalDateTime.now());
    user.setUpdateTime(LocalDateTime.now());
    save(user);  // ← 这行代码把 user 存进数据库
    return user;
}

执行过程:

java 复制代码
text

user 对象(内存中)
    ↓
save(user)
    ↓
MyBatis-Plus 自动生成 SQL
    ↓
INSERT INTO user (phone, nick_name, ...) VALUES (?, ?, ...)
    ↓
数据库执行插入
    ↓
数据库里多了一条用户记录
  1. save(user) 的返回值
java 复制代码
java

boolean success = save(user);  // true=插入成功,false=失败
  1. 为什么不用写 SQL

MyBatis-Plus 通过实体类映射自动生成 SQL:

java 复制代码
java

// User 实体类
@Data
@TableName("user")  // 指定表名
public class User {
    @TableId(type = IdType.AUTO)  // 主键自增
    private Long id;
    
    private String phone;        // 对应数据库 phone 字段
    private String nickName;     // 对应数据库 nick_name 字段
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

MyBatis-Plus 看到 save(user) 会:

  1. 读取 @TableName("user") → 知道要插入 user

  2. 读取对象的字段值 → 提取 phonenickName 等值

  3. 自动生成 INSERT SQL 并执行

MyBatis-Plus 常用方法速查

继承 ServiceImpl 后可以直接用的方法

java 复制代码
java

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    
    public void demo() {
        // 插入
        save(user);                    // 插入一条
        saveBatch(userList);           // 批量插入
        
        // 删除
        removeById(1L);                // 根据 ID 删除
        removeByIds(Arrays.asList(1,2,3));  // 批量删除
        
        // 更新
        updateById(user);              // 根据 ID 更新
        update(user, updateWrapper);   // 条件更新
        
        // 查询
        getById(1L);                   // 根据 ID 查询
        list();                        // 查询所有
        listByIds(Arrays.asList(1,2)); // 批量查询
        count();                       // 查询总数
        
        // 条件查询(Lambda 方式)
        query().eq("phone", "13800138000").one();      // 等值查询单条
        query().eq("phone", phone).list();             // 等值查询多条
        lambdaQuery().eq(User::getPhone, phone).one(); // Lambda 方式(推荐)
    }
}

代码实现:登陆校验拦截器

拦截器在请求到达 Controller 之前,先判断用户是否已登录,未登录则直接拦截返回,不让访问需要登录的接口。

复制代码
用户请求
    ↓
拦截器(preHandle)
    ↓
判断 Session 中是否有 user
    ↓
┌─────────────┴─────────────┐
↓                           ↓
有(已登录)                 无(未登录)
↓                           ↓
放行,进入 Controller       拦截,返回 401

自定义类:LoginInterceptor

实现接口的前置拦截和后置拦截

java 复制代码
  @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //获取session
        HttpSession session = request.getSession();
        //获取session的用户
        Object user = session.getAttribute("user");
        //判断用户是否存在
        //如果不存在,拦截
        if (user==null){
            response.setStatus(401);
            return false;
        }
        //若存在,则保存到ThreadLocal
        User loginUser = (User) user;
        UserDTO userDTO = new UserDTO();
        userDTO.setId(loginUser.getId());
        userDTO.setNickName(loginUser.getNickName());
        userDTO.setIcon(loginUser.getIcon());
        UserHolder.saveUser(userDTO);

        return true;
    }

配置拦截器:

java 复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {
   /**
     * 添加拦截器
     * @param registry
     */
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                );
        WebMvcConfigurer.super.addInterceptors(registry);
    }
}

User 对象可能包含 password、phone 等敏感字段,直接返回给前端会泄露隐私,因此我们返回给前端的不能是User对象,而是UserDTO,因此我们把在登录用户时存入session的用户类型变成DTO,通过对象属性拷贝来给DTO里面的属性赋值

java 复制代码
   session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

之后我们在拦截器和Controller接口返回的都是UserDTO,不包含敏感信息

复制代码
【登录时】
数据库 User 实体(包含 password、phone 等所有字段)
    ↓
转换为 UserDTO(只包含 id、nickName、icon 等公开字段)
    ↓
session.setAttribute("user", userDTO)  ← Session 中存的是 UserDTO
    ↓
【后续请求】
拦截器从 Session 取出
    ↓
UserDTO userDTO = (UserDTO) session.getAttribute("user")  ← 取出的是 UserDTO
    ↓
UserHolder.setUser(userDTO)  ← ThreadLocal 存的是 UserDTO
    ↓
Controller/Service 中使用
    ↓
UserDTO currentUser = UserHolder.getUser()  ← 拿到的也是 UserDTO
    ↓
返回给前端(只包含公开字段,安全)

结语:

如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!

相关推荐
广州灵眸科技有限公司2 小时前
瑞芯微(EASY EAI)RV1126B 系统操作-线进程操作
开发语言·科技·嵌入式硬件·物联网
Bat U2 小时前
JavaEE|计算机是如何工作的
java·人工智能
~plus~2 小时前
C#/.NET 8 Span与Memory高性能编程完全指南
开发语言·c#·.net
zore_c2 小时前
【C++】基础语法(命名空间、引用、缺省以及输入输出)
c语言·开发语言·数据结构·c++·经验分享·笔记
Master_清欢2 小时前
解决dify插件无限循环的问题
开发语言
许彰午2 小时前
# 政务表单动态建表?运行时DDL引擎,前端拖完字段后端直接建
java·前端·后端·架构·政务
我登哥MVP2 小时前
【Spring6笔记】 - 13 - 面向切面编程(AOP)
java·开发语言·spring boot·笔记·spring·aop
宸津-代码粉碎机2 小时前
Spring Boot 4.0 进阶实战+源码解析系列(持续更新)—— 从落地到源码,搞定面试与工作
java·人工智能·spring boot·后端·python·面试
沐雪轻挽萤2 小时前
2. C++17新特性-结构化绑定 (Structured Bindings)
java·开发语言·c++