

🔥个人主页:北极的代码(欢迎来访)
🎬作者简介:java后端学习者
✨命运的结局尽可永在,不屈的挑战却不可须臾或缺!
前言:
前一天我们导入了黑马点评项目,并配置好了环境,那么从今天开始,我们就要开始完成项目的相关业务操作了,今天实现的是短信登录功能。
摘要:
本文介绍了基于Session和ThreadLocal的短信验证码登录实现方案。主要内容包括:1. 验证码登录流程分为发送验证码和登录注册两个阶段,验证码和用户信息存储在Session中;2. 使用ThreadLocal作为临时用户信息传递工具,避免代码臃肿;3. 详细阐述了Session存储验证码和用户信息的必要性及优势;4. 提供了完整的代码实现示例,包括发送验证码、登录注册接口及拦截器实现;5. 强调了使用UserDTO而非User实体类来保护敏感信息。该方案利用Session维持登录状态,通过ThreadLocal实现用户信息在请求各层间的优雅传递
关于Session和ThreadLocal
基于Session的短信验证码登录流程
主要分为两个阶段:发送验证码 和 登录注册。
1. 发送验证码流程
前端:用户输入手机号,点击"获取验证码"。
后端:
接收手机号,进行基本校验(非空、格式)。
生成一个随机6位数字验证码(如 123456)。
关键步骤:将验证码存入 HttpSession。
Key:一个固定字符串或与手机号关联的Key,例如 "code" 或 "login:code:"+phone。
Value:验证码本身。
调用短信服务商API发送验证码(生产环境)。在测试/教学阶段,通常直接打印到控制台,或模拟发送。
返回成功提示。
2. 登录注册流程
前端:用户输入手机号 + 收到的验证码,点击登录。
后端:
接收手机号和验证码。
从Session中获取之前存储的验证码(根据相同的Key)。
校验:判断用户提交的验证码 是否等于 Session中存储的验证码,并且验证码是否过期(Session有超时时间)。
不一致或不存在:返回"验证码错误"。
一致:继续。
查询用户:根据手机号查询数据库。
分支处理:
已存在用户:直接获取用户信息。
不存在用户(注册):创建新用户,填充手机号、默认昵称(如user_+随机数)、默认头像等,保存到数据库,然后获取这个新用户。
登录态保持:将查询到的用户对象(或用户信息,如ID、昵称、头像)存入 Session。
Key:例如 "user"。
Value:用户对象。
返回成功(通常前端会跳转到首页)。
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,防止内存泄漏 ↓ 【响应返回给客户端】关键点:
用户信息的主存储位置是 Session(服务器内存)
ThreadLocal 只是当前请求的"临时缓存",方便各层代码获取用户
请求结束后,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)
save(user)是什么意思?
sqljava save(user); // 把 user 对象插入到数据库等价于这条 SQL:
sqlsql 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')
- 完整代码示例
javajava // 你的代码 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; }执行过程:
javatext user 对象(内存中) ↓ save(user) ↓ MyBatis-Plus 自动生成 SQL ↓ INSERT INTO user (phone, nick_name, ...) VALUES (?, ?, ...) ↓ 数据库执行插入 ↓ 数据库里多了一条用户记录
save(user)的返回值
javajava boolean success = save(user); // true=插入成功,false=失败
- 为什么不用写 SQL
MyBatis-Plus 通过实体类映射自动生成 SQL:
javajava // 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)会:
读取
@TableName("user")→ 知道要插入user表读取对象的字段值 → 提取
phone、nickName等值自动生成 INSERT SQL 并执行
MyBatis-Plus 常用方法速查
继承
ServiceImpl后可以直接用的方法
javajava @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 ↓ 返回给前端(只包含公开字段,安全)
结语:
如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!



