day30 黑马点评短信登录与Session实现

day30 - 黑马点评短信登录与Session实现

一、项目导入

1.1 黑马点评项目结构

黑马点评(hm-dianping)是一个基于 Spring Boot 的点评类项目,本次学习从导入项目开始。项目采用分层架构,核心包结构如下:

1
2
3
4
5
6
7
8
com.hmdp
├── config # 配置类(MvcConfig、MybatisConfig 等)
├── controller # 控制层
├── dto # 数据传输对象(LoginFormDTO、UserDTO、Result 等)
├── entity # 实体类(User、Shop 等)
├── mapper # MyBatis-Plus Mapper 接口
├── service # 业务接口与实现
└── utils # 工具类(LoginInterceptor、UserHolder、RedisConstants 等)

1.2 短信登录的整体流程

短信登录分为三步:

  1. 发送短信验证码:用户提交手机号,后台校验格式后生成 6 位验证码,存入 Session。
  2. 验证码登录:用户提交手机号和验证码,后台从 Session 取出验证码比对,一致则根据手机号查询用户,不存在则自动创建,最后将用户信息存入 Session。
  3. 校验登录状态:后续请求携带 Cookie 中的 JSESSIONID,后台通过拦截器从 Session 取出用户信息,未登录则拦截。
1
发送验证码 → 验证码登录(查用户/创建用户)→ 拦截器校验登录状态

二、基于 Session 实现短信登录

2.1 对 Session 的理解

每个 Tomcat 服务器维护一个 Session 池。当浏览器发起请求并调用 session.setAttribute(key, value) 时:

  1. 服务器为该浏览器生成一个 SessionID;
  2. 创建一个 Session 对象,将字段名与字段值存入其中;
  3. 将 SessionID 通过 Cookie 返回给浏览器;
  4. 下次请求时浏览器携带该 SessionID,服务器据此找到对应的 Session 对象。

Session 的核心作用是在无状态的 HTTP 协议下保存用户的登录状态。

2.2 发送短信验证码

接口:POST /user/code,参数为手机号。实现位于 UserServiceImpl.sendCode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public Result sendCode(String phone, HttpSession session) {
// 1. 校验手机号格式
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
// 2. 生成 6 位数字验证码
String code = RandomUtil.randomNumbers(6);
// 3. 将验证码存入 session
session.setAttribute("code", code);
// 4. 模拟发送短信(打印日志)
log.debug("发送验证码到手机号:{},验证码:{}", phone, code);
return Result.ok();
}

关键点:

  • RegexUtils.isPhoneInvalid 校验手机号格式,不合法直接返回错误;

  • RandomUtil.randomNumbers(6) 来自 Hutool,生成 6 位随机数字;

  • 验证码以 "code" 为 key 存入 Session,供登录时比对。

2.3 验证码登录

接口:POST /user/login,参数为 LoginFormDTO(包含 phone、code、password)。实现位于 UserServiceImpl.login:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
// 2. 从 session 取出验证码并比对
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !code.equals(cacheCode.toString())) {
return Result.fail("验证码错误");
}
// 3. 根据手机号查询用户
User user = query().eq("phone", phone).one();
if (user == null) {
// 4. 不存在则自动创建用户
user = createUserWithPhone(phone);
}
// 5. 保存用户信息到 session
session.setAttribute("user", user);
return Result.ok();
}

登录逻辑:校验手机号 → 校验验证码 → 查询用户 → 不存在则创建 → 存入 Session。

2.4 自动创建用户

createUserWithPhone 方法在用户不存在时自动创建账号:

1
2
3
4
5
6
7
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName("user_" + RandomUtil.randomString(10));
save(user);
return user;
}

昵称随机生成,格式为 user_ + 10 位随机字符串。

2.5 MyBatis-Plus 快捷操作

本项目使用 MyBatis-Plus,无需编写 Mapper 层的 SQL,直接通过 Service 层的链式方法操作数据库:

方法 作用
query().eq("phone", phone).one() 条件查询单条记录
save(user) 插入一条记录

UserServiceImpl 继承 ServiceImpl<UserMapper, User>,从而获得 query()、save() 等方法。

2.6 @TableId 主键自增

User 实体的主键使用 @TableId 注解,声明主键字段和自增策略:

1
2
@TableId(value = "id", type = IdType.AUTO)
private Long id;

IdType.AUTO 表示使用数据库自增主键。调用 save(user) 后,数据库生成的主键 id 会回填到 user 对象中,后续可以直接通过 user.getId() 获取。

@TableId 的 type 属性还有 ASSIGN_ID(雪花算法)、INPUT(手动输入)等取值,本项目使用数据库自增。


三、登录校验:拦截器与 ThreadLocal

3.1 拦截器实现登录校验

LoginInterceptor 实现 HandlerInterceptor,在请求进入 Controller 前拦截:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
HttpSession session = request.getSession();
Object user = session.getAttribute("user");
if (user == null) {
// 未登录,返回 401
response.setStatus(401);
return false;
}
// 已登录,将用户信息存入 ThreadLocal
UserHolder.saveUser(BeanUtil.copyProperties((User) user, UserDTO.class));
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 请求结束后清除 ThreadLocal,防止内存泄漏
UserHolder.removeUser();
}

拦截逻辑:从 Session 取 user → 为空返回 401 拦截 → 不为空则存入 ThreadLocal 并放行。

3.2 ThreadLocal 保存用户信息

UserHolder 封装了基于 ThreadLocal 的用户存取:

1
2
3
4
5
6
7
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:Tomcat 从线程池中取一个线程处理每个请求,请求结束后线程归还线程池。ThreadLocal 以当前线程为 key 存储数据,保证每个线程只能访问自己的那份用户信息,实现线程隔离。Controller 中通过 UserHolder.getUser() 获取当前登录用户。

注意:必须在 afterCompletion 中调用 removeUser() 清除 ThreadLocal,否则线程复用会导致数据错乱和内存泄漏。

3.3 注册拦截器

MvcConfig 中注册拦截器,并放行不需要登录的路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/user/code", "/user/login",
"/blog/hot", "/shop/**",
"/shop-type/**", "/voucher/**", "/upload/**"
);
}
}

/user/code 和 /user/login 必须放行,否则用户无法获取验证码和登录。


四、UserDTO 隐藏敏感信息

4.1 为什么用 UserDTO

User 实体包含 password 等敏感字段,直接存入 Session 并返回前端存在安全风险。UserDTO 只保留必要字段:

1
2
3
4
5
6
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}

使用 UserDTO 的好处:

  • 避免敏感信息返回前端:不暴露 password 等字段;

  • 节省内存:只传递业务需要的字段。

4.2 User 转 UserDTO

项目中有两种对象拷贝方式,注意区分:

方式 来源 写法
BeanUtil.copyProperties Hutool BeanUtil.copyProperties(user, UserDTO.class)
BeanUtils.copyProperties Spring BeanUtils.copyProperties(user, userDTO)

本项目拦截器中使用 Hutool 的 BeanUtil:

1
UserHolder.saveUser(BeanUtil.copyProperties((User) user, UserDTO.class));

注意 BeanUtil(Hutool)和 BeanUtils(Spring)名字相似,前者的第二个参数可以直接传目标类的 Class,后者需要传入目标对象实例。


五、Session 共享问题

5.1 问题描述

在集群部署时,每台 Tomcat 服务器的 Session 是独立的:

1
2
用户请求 → 负载均衡 → Tomcat1(有 session)
↘ Tomcat2(没有 session)→ 登录失效

用户第一次请求落到 Tomcat1 并在其 Session 中保存了登录信息,第二次请求可能被负载均衡分发到 Tomcat2,而 Tomcat2 上没有该用户的 Session,导致登录校验失败。

5.2 传统方案:Session 拷贝

早期通过 Session 拷贝实现共享:任意一台服务器的 Session 修改时,同步给其他 Tomcat。但存在两个问题:

  1. 每台服务器都保存完整的 Session 数据,服务器压力过大;
  2. Session 拷贝存在延迟,可能导致短时间内数据不一致。

5.3 解决方案:用 Redis 代替 Session

Redis 数据本身是共享存储的,多台 Tomcat 都访问同一个 Redis,天然解决 Session 共享问题。Redis 实现登录的具体代码明日再学。


今日总结

模块 核心要点
项目结构 黑马点评采用 Controller → Service → Mapper 分层,集成 MyBatis-Plus
短信登录 发送验证码(存 Session)→ 验证码登录(查/建用户,存 Session)→ 拦截器校验
Session 原理 每台 Tomcat 有 Session 池,通过 SessionID(Cookie)关联浏览器与 Session 对象
MyBatis-Plus 继承 ServiceImpl 获得 query()、save() 等方法,无需写 Mapper SQL
@TableId IdType.AUTO 声明自增主键,save 后主键回填到实体
登录校验 LoginInterceptor 从 Session 取用户,未登录返回 401;已登录存入 ThreadLocal
ThreadLocal 以当前线程为 key 存储数据,实现线程隔离;请求结束必须 remove 防泄漏
UserDTO 只保留 id、nickName、icon,隐藏 password 等敏感信息,节省内存
对象拷贝 Hutool BeanUtil.copyProperties(user, UserDTO.class) 与 Spring BeanUtils 区分
Session 共享 集群下每台 Tomcat Session 独立;传统拷贝有压力和延迟问题;用 Redis 代替可解决