短信登录:基于 Session 实现(黑马点评实战)

相比传统账号密码登录,短信登录具有无需记忆密码、操作便捷、安全性较高等优点。本文将以黑马点评(hm-dianping)项目为背景,详细讲解如何基于 Session 实现一套完整的短信验证码登录功能。内容包括业务流程设计、核心代码实现、登录状态拦截以及 Session 方案的局限性分析。

一、业务流程与设计思路

在动手编码之前,我们先理清基于 Session 的短信登录整体流程。如下图所示:

复制代码
+--------+          +-------------+          +-----------------+
|  前端   |  请求    |   后端接口   |   操作   |      Session     |
+--------+   <-->   +-------------+   <-->   +-----------------+
    |                   |                            |
    | 1. 提交手机号       | 2. 生成验证码                | 3. 存储验证码
    | 4. 提交手机号+验证码 | 5. 校验验证码、查询/创建用户  | 6. 读取验证码,存储用户信息
    | 7. 后续请求携带Cookie | 8. 拦截器校验登录状态        | 9. 获取用户信息

详细步骤分解如下:

  1. 发送验证码:用户输入手机号,点击"获取验证码"。后端校验手机号格式后,生成随机验证码,并将验证码保存到当前会话(Session)中,最后通过短信通道发送给用户(开发阶段通常直接打印到控制台)。

  2. 登录/注册:用户输入手机号和收到的验证码,点击登录。后端从 Session 中取出之前保存的验证码进行比对。如果一致,则根据手机号查询用户;若用户不存在,则自动创建一个新用户(即注册)。最后将用户信息存入 Session,标记用户已登录。

  3. 登录状态校验 :对于需要登录才能访问的接口(如个人中心、下单等),通过拦截器 拦截请求,从 Session 中获取用户信息。若存在,则将用户信息存入 ThreadLocal 供后续业务使用,并放行请求;否则直接返回未授权状态。

整个流程的核心是依赖 Tomcat 的 Session 机制 ,即服务器为每个浏览器会话创建一个唯一的 JSESSIONID,并通过 Cookie 在客户端和服务端之间传递,从而实现会话的保持。

二、环境准备与工具类

黑马点评项目基于 Spring Boot,我们首先确保引入了必要的依赖。这里使用 hutool 工具包简化验证码生成、Bean 复制等操作。

XML 复制代码
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.16</version>
</dependency>

此外,项目中通常包含一个 RegexUtils 工具类,用于校验手机号格式(例如使用正则 ^1[3-9]\\d{9}$)。

三、核心功能实现

3.1 发送短信验证码

Controller 层:接收手机号,调用 Service 处理。

java 复制代码
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private IUserService userService;

    @PostMapping("/code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        return userService.sendCode(phone, session);
    }
}

Service 层实现

java 复制代码
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @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 本身可配置超时)
        session.setAttribute("code", code);

        // 4. 模拟发送短信(实际中调用短信服务商 API)
        log.debug("短信验证码:{}", code);

        return Result.ok();
    }
}

说明 :验证码存入 Session 的 key 为 "code",后续登录时会从中取出比对。注意 Session 的默认过期时间为 30 分钟,一般验证码有效期较短(如 5 分钟),但基于 Session 管理过期时间较为复杂,通常需要结合定时任务或额外字段处理。这也是 Session 方案的一个小缺陷。

3.2 短信验证码登录/注册

登录表单 DTO

java 复制代码
@Data
public class LoginFormDTO {
    private String phone;
    private String code;
}

Controller 层

java 复制代码
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
    return userService.login(loginForm, session);
}

Service 层实现

java 复制代码
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    String phone = loginForm.getPhone();
    String code = loginForm.getCode();

    // 1. 校验手机号格式
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误!");
    }

    // 2. 从 Session 获取验证码并校验
    String cacheCode = (String) session.getAttribute("code");
    if (cacheCode == null || !cacheCode.equals(code)) {
        return Result.fail("验证码错误!");
    }

    // 3. 根据手机号查询用户
    User user = query().eq("phone", phone).one();

    // 4. 用户不存在,自动注册
    if (user == null) {
        user = createUserWithPhone(phone);
    }

    // 5. 保存用户信息到 Session(使用 UserDTO 隐藏敏感字段)
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    session.setAttribute("user", userDTO);

    // 可删除验证码,防止重复使用
    session.removeAttribute("code");

    return Result.ok(userDTO);
}

// 辅助方法:创建新用户
private User createUserWithPhone(String phone) {
    User user = new User();
    user.setPhone(phone);
    // 生成默认昵称,如 "user_随机字符串"
    user.setNickName("user_" + RandomUtil.randomString(10));
    save(user);
    return user;
}

关键点

  • 使用 UserDTO 代替完整的 User 实体存入 Session,避免敏感信息(如密码、手机号)暴露。UserDTO 通常只包含 idnickNameicon 等字段。

  • 登录成功后,移除 Session 中的验证码,防止同一验证码被重复使用。

  • 返回 UserDTO 给前端,前端可用于展示用户信息。

3.3 登录状态校验拦截器

为了统一处理需要登录的接口,我们使用 拦截器 + ThreadLocal 实现。首先定义一个 UserHolder 工具类,用于在当前线程中保存和获取用户信息。

java 复制代码
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();
    }
}

然后编写拦截器 LoginInterceptor

java 复制代码
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 从 Session 中获取用户
        HttpSession session = request.getSession(false); // 不创建新 Session
        if (session == null) {
            response.setStatus(401);
            return false;
        }
        UserDTO user = (UserDTO) session.getAttribute("user");

        // 2. 用户不存在,未登录,返回 401
        if (user == null) {
            response.setStatus(401);
            return false;
        }

        // 3. 用户存在,保存到 ThreadLocal
        UserHolder.saveUser(user);
        return true;
    }

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

说明

  • request.getSession(false) 表示如果当前请求没有关联 Session,则返回 null,避免创建无用 Session。

  • 未登录时直接设置 HTTP 状态码 401(未授权),前端可据此跳转登录页。

  • 将用户信息存入 ThreadLocal,后续业务代码(如 Controller、Service)可以直接通过 UserHolder.getUser() 获取当前登录用户,无需再操作 Session。

最后,在配置类中注册拦截器,并设置放行路径:

java 复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {

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

3.4 在业务中使用当前登录用户

以查询当前用户信息为例:

java 复制代码
@GetMapping("/me")
public Result me() {
    UserDTO user = UserHolder.getUser();
    return Result.ok(user);
}

这样既简洁又安全,业务代码无需关心 Session 的获取细节。

四、基于 Session 方案的局限性

以上实现完整演示了基于 Session 的短信登录。但在实际生产环境,尤其是分布式集群部署时,该方案会遇到以下问题:

4.1 Session 共享问题

当后端服务部署在多台 Tomcat 上,且前端通过 Nginx 进行负载均衡时,用户的第一次请求可能落在 Server1,Session 创建在 Server1;第二次请求如果落在 Server2,Server2 无法获取 Server1 的 Session,就会误认为用户未登录,导致登录状态丢失。

4.2 传统解决方案的不足

早期常用的解决方案是 Session 黏着(Sticky Session)Session 复制

  • Session 黏着:通过 Nginx 配置,将同一用户的请求始终转发到同一台服务器。但一旦该服务器宕机,会话即丢失,且负载均衡能力下降。

  • Session 复制:服务器之间同步 Session 数据(如 Tomcat 的 DeltaManager)。但这种方式会带来大量的网络开销和数据冗余,集群规模大时性能急剧下降。

4.3 现代解决方案:引入 Redis

为了解决上述问题,业界普遍采用 外部集中式存储 的方式,即使用 Redis 统一管理登录状态。具体思路:

  • 发送验证码时,将验证码存入 Redis,并设置过期时间(如 2 分钟)。

  • 登录成功后,生成一个随机的 Token(如 UUID),将用户信息存入 Redis,并将 Token 返回给前端。前端后续请求在请求头中携带 Token。

  • 拦截器从请求头获取 Token,查询 Redis 获取用户信息,并实现 Token 续期。

这种方式不仅解决了 Session 共享问题,还能更好地控制过期时间、支持分布式环境。黑马点评项目后续也正是采用 Redis 重构登录功能。

五、总结

本文以黑马点评项目为基础,详细讲解了基于 Session 的短信验证码登录实现。从业务流程出发,逐步完成了验证码发送、登录注册、拦截器校验等核心代码,并介绍了如何使用 ThreadLocal 优化用户信息传递。

虽然 Session 方案简单易懂,适合单体应用或学习阶段,但在分布式环境下存在明显的共享问题。通过分析其局限性,我们引出了基于 Redis 的改进方向,为后续学习打下基础。


参考资料

  • 黑马点评项目源码

  • Hutool 官方文档

  • Spring 拦截器与 ThreadLocal 使用指南

相关推荐
北风toto1 小时前
JDK8(JAVA)供应商说明
java·开发语言
清水白石0081 小时前
观察者模式全解析:用 Python 构建优雅的事件系统,让组件彻底解耦
java·python·观察者模式
xiaoccii1 小时前
C++(入门版)
java·c++·算法
上下求索,莫负韶华1 小时前
java-(double,BigDecimal),sql-(decimal,nuermic)
java·开发语言·sql
xianyudx1 小时前
Linux 服务器 DNS 配置指南 (CentOS 7 / 麒麟 V10)
linux·服务器·centos
ID_180079054732 小时前
淘宝商品详情 API 接口 item_get: 高效获取商品数据的技术方案
java·前端·数据库
间彧2 小时前
布隆过滤器详解与Redis+Spring Boot实战指南
java
一念杂记2 小时前
玩Huggingface免费服务器(2vCPU+16GRAM+100G空间)系列领取免费服务器保姆级教程
服务器·ai编程
躲在云朵里`2 小时前
Jeecgboot框架-权限控制
java·开发语言