

前言:
前一天我们导入了黑马点评项目,并配置好了环境,那么从今天开始,我们就要开始完成项目的相关业务操作了,今天实现的是短信登录功能。
摘要:
本文介绍了基于Session和ThreadLocal的短信验证码登录实现方案。主要内容包括:1. 验证码登录流程分为发送验证码和登录注册两个阶段,验证码和用户信息存储在Session中;2. 使用ThreadLocal作为临时用户信息传递工具,避免代码臃肿;3. 详细阐述了Session存储验证码和用户信息的必要性及优势;4. 提供了完整的代码实现示例,包括发送验证码、登录注册接口及拦截器实现;5. 强调了使用UserDTO而非User实体类来保护敏感信息。该方案利用Session维持登录状态,通过ThreadLocal实现用户信息在请求各层间的优雅传递
主要分为两个阶段:发送验证码 和 登录注册。

123456)。
HttpSession。
“code” 或 “login:code:”+phone。

user_+随机数)、默认头像等,保存到数据库,然后获取这个新用户。
Session。
“user”。

【用户请求】GET /user/order
↓
① 请求进入 Tomcat(携带 Cookie: JSESSIONID=ABC123)
↓
② Spring MVC 查找对应的拦截器
↓
③ 拦截器 preHandle 方法执行
↓
④ 从 Session 获取用户信息
↓
⑤ 判断是否已登录
↓
┌─────────────────┴─────────────────┐
↓ ↓
【已登录】 【未登录】
↓ ↓
存入 ThreadLocal 返回 401 状态码
↓ ↓
放行到 Controller 拦截,不进入 Controller
↓ ↓
执行业务逻辑 响应返回前端
↓
返回数据给前端概念 | 存储位置 | 生命周期 | 作用 |
|---|---|---|---|
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,防止内存泄漏
↓
【响应返回给客户端】方案 | 代码示例 | 问题 |
|---|---|---|
不用 ThreadLocal | Controller: session.getAttribute("user") Service: 需要传入 user 参数 Mapper: 需要继续传 | 参数到处传递,代码臃肿 |
用 ThreadLocal | 任何地方直接 UserHolder.getUser() | 优雅、解耦 |
这是基于 HTTP协议的无状态性 和 Session的服务端存储机制 决定的。 1. 为什么验证码放Session里 核心目的:将“服务端发出的验证码”与“当前用户的浏览器会话”绑定,防止跨请求伪造和盗用。
138****0000 发了验证码 123456。这个验证码只应该属于这个用户的这次请求。Session天然与一个特定用户(通过Cookie中的JSESSIONID标识)绑定。当用户提交验证码时,服务端从该用户的Session中取出验证码进行比对,确保了 “这个验证码确实是发给这个浏览器的”。
2. 为什么用户信息放Session里 核心目的:实现登录态跟踪,避免每次请求都查询数据库。
项目 | 内容 |
|---|---|
接口名称 | 发送短信验证码 |
请求方式 | POST |
请求路径 | /user/code |
请求参数 | phone(手机号) |
响应格式 | JSON |
核心逻辑 | 校验手机号 → 生成验证码 → 存入Session → 发送短信(模拟) |
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}实现Controller没有的方法,然后在这里实现具体的业务操作
/**
* 发送验证码
* @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 |
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm,session);
} /**
* 登录和注册功能
* @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,
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) |
2. save(user) 是什么意思?
java
save(user); // 把 user 对象插入到数据库等价于这条 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')3. 完整代码示例
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;
}执行过程:
text
user 对象(内存中)
↓
save(user)
↓
MyBatis-Plus 自动生成 SQL
↓
INSERT INTO user (phone, nick_name, ...) VALUES (?, ?, ...)
↓
数据库执行插入
↓
数据库里多了一条用户记录4. save(user) 的返回值
java
boolean success = save(user); // true=插入成功,false=失败5. 为什么不用写 SQL
MyBatis-Plus 通过实体类映射自动生成 SQL:
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) 会:
@TableName("user") → 知道要插入 user 表
phone、nickName 等值
继承 ServiceImpl 后可以直接用的方法
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实现接口的前置拦截和后置拦截
@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;
}@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里面的属性赋值
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
↓
返回给前端(只包含公开字段,安全)结语:
如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!
