一、短信登录要解决什么问题

这次实现的功能是短信登录,它的具体代码需求有:

  • 输入手机号并发送短信验证码
  • 校验手机号和短信验证码
  • 用户不存在时自动注册
  • 登录成功后保存用户登录状态
  • 拦截未登录请求并返回 401
  • 在登录状态有效期内自动刷新凭证

黑马点评项目先使用 Session 实现,再改造成 Redis+Token。前者适合单机环境下理解流程,后者解决多实例部署和多端请求的问题。

二、Session 方案实现短信登录

2.1 发送验证码

Controller 只负责接收请求参数并转交 Service:

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

Service 中先校验手机号,再生成 6 位数字验证码,最后放入 Session:

public Result sendCode(String phone, HttpSession session) {
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }

    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
    // 4.保存验证码到 Session
    session.setAttribute("code", code);
    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{}", code);
    // 6.返回 OK
    return Result.ok();

手机号是请求输入,Session 是服务端按会话保存验证码的位置。登录请求到达后,后端用同一个 Session 取出验证码并进行比较。

2.2 验证码登录与自动注册

登录接口接收手机号和验证码:

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

Service 的核心流程是“校验验证码、查用户、没有就创建、保存登录用户”:

// 1.校验验证码
Object cacheCode = session.getAttribute("code");
if (cacheCode == null || !cacheCode.toString().equals(loginForm.getCode())) {
    // 2.验证码不一致,报错
    return Result.fail("验证码错误");
}

// 3.一致,根据手机号查询用户
User user = query().eq("phone", loginForm.getPhone()).one();
// 4.判断用户是否存在
if (user == null) {
    // 5.不存在,则创建
    user = createUserWithPhone(loginForm.getPhone());
}

// 6.保存用户信息到 Session
session.setAttribute("user", BeanUtils.copyProperties(user, UserDTO.class));
return Result.ok();

第六段功能:手机号查询依赖数据库中的唯一用户记录。用户不存在时自动创建,昵称由系统前缀和随机字符串组成。

Session 中保存 UserDTO,可以避免把实体中的非登录字段直接暴露给后续业务。比如手机号,密码等,用DTO保存在互联网上的变量更少更安全.

2.3 单拦截器校验登录状态

Session 方案通过 HandlerInterceptor 在 Controller 执行前检查登录状态。拦截器从会话中读取用户:


public boolean preHandle(HttpServletRequest request,
                         HttpServletResponse response,
                         Object handler) {
    // 1.获取 Session
    HttpSession session = request.getSession();
    // 2.获取 Session 中的用户
    Object user = session.getAttribute("user");
    if (user == null) {
        // 3.不存在,拦截,返回 401 状态码
        response.setStatus(401);
        return false;
    }

    // 4.存在,保存用户信息到 ThreadLocal
    UserHolder.saveUser((UserDTO) user);
    // 5.放行
    return true;
}

仅有拦截器类还不够,还需要在 Spring MVC 配置类中注册它,并明确哪些接口允许未登录访问:

@Configuration
public class MvcConfig implements WebMvcConfigurer {

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

addInterceptor 注册登录拦截器,未配置排除路径的请求都会进入 preHandle/user/code/user/login 是登录前必须访问的接口,必须放行;/shop/**/voucher/**/shop-type/**/upload/**/blog/hot 属于公开访问接口,也不要求用户先登录。其他路径默认需要登录。

这两部分要连起来理解:LoginInterceptor 负责判断“当前请求有没有登录用户”,MvcConfig 负责规定“哪些请求需要进行这个判断”。请求进入受保护路径时,拦截器从 Session 取出用户并写入 UserHolder,后续业务代码就能通过 UserHolder 获取当前用户;请求完成后应调用 remove() 清理 ThreadLocal,避免线程复用时残留上一次请求的用户信息。**

三、为什么从 Session 改成 Redis+Token

Session 默认保存在单个 Tomcat 实例内存中。用户第一次请求落到实例 A,第二次请求落到实例 B 时,B 找不到 A 创建的 Session,就会把已登录用户当成未登录用户。

Session 共享可以解决这个问题,但需要额外同步会话数据。Redis 本身就是多实例可访问的集中式存储,因此项目把验证码和登录用户状态放入 Redis,再把一个 Token 返回给前端。浏览器、App 和小程序都可以在请求头中主动携带 Token。

四、Redis+Token 方案改造

4.1 Redis 版发送验证码

// 1.生成验证码
String code = RandomUtil.randomNumbers(6);
// 2.保存验证码到 Redis
stringRedisTemplate.opsForValue().set(
        LOGIN_CODE_KEY + phone,
        code,
        LOGIN_CODE_TTL,
        TimeUnit.MINUTES
);

验证码由 StringRedisTemplate 写入 Redis,Key 使用 login:code:{phone}。手机号保证不同用户的验证码互不覆盖,TTL 负责自动删除过期验证码。当前项目常量集中定义在 RedisConstants 中,便于统一修改 Key 前缀和有效期。

4.2 Redis 版登录与自动注册

登录时按手机号读取 Redis 验证码:

// 1.从 Redis 中获取验证码
String rightCode = stringRedisTemplate.opsForValue()
        .get(LOGIN_CODE_KEY + phone);
if (rightCode == null || !rightCode.equals(loginForm.getCode())) {
    // 2.验证码不一致,返回错误
    return Result.fail("验证码不正确");
}

验证码正确后,数据库查询和自动注册仍然由 MyBatis-Plus 完成。登录成功后生成 UUID Token,把用户 DTO 转成 Map 保存到 Redis Hash,并设置过期时间:

// 1.生成 Token
String token = UUID.randomUUID().toString(true);
// 2.将 UserDTO 转换为 Hash
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
        CopyOptions.create()
                .setIgnoreNullValue(true)
                .setFieldValueEditor((fieldName, value) ->
                        value == null ? "" : value.toString()));

// 3.保存用户信息到 Redis
String key = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(key, userMap);
// 4.设置过期时间
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.SECONDS);
// 5.返回 Token
return Result.ok(token);

前端保存返回的 Token,后续请求通过 Authorization 请求头携带。后端不再依赖 JSESSIONID,而是用 Token 找到 Redis 中的用户信息。

五、Redis Key-Value:String 与 Hash 的差异

5.1 String:保存单个值

String 的结构是一个 Key 对应一个 Value,适合验证码、计数器和简单状态。短信验证码只需要保存一段数字,因此使用 opsForValue()

login:code:13800000000 -> 123456

5.2 Hash:保存对象字段

Hash 的结构是一个 Key 下包含多个 Field-Value,适合用户这种有多个属性的对象:

login:token:xxx
    id -> 1
    nickName -> user_xxx
    icon -> avatar.png

项目使用 opsForHash().putAll 保存 UserDTO 转换后的字段。读取时可以一次获得多个字段,再通过 BeanUtil 组装回 UserDTO。

5.3 两种结构的选择

对比项StringHash
数据结构Key-ValueKey-Field-Value
适合数据单个简单值多字段对象
当前用途短信验证码登录用户信息
Java APIopsForValue()opsForHash()
过期对象整个 Key整个 Hash Key

选择 Redis 类型时,先看业务数据是一个值还是一组字段,再决定存储粒度。验证码不需要拆字段,用户信息则不适合拼成难以局部读取的单一值。

六、MyBatis-Plus 在登录功能中的应用

6.1 ServiceImpl 提供通用能力

当前 UserServiceImpl 继承:

public class UserServiceImpl
        extends ServiceImpl<UserMapper, User>
        implements IUserService {
}

第一个泛型是 Mapper 类型,第二个是实体类型。继承后可以直接使用 query()getById()save() 等通用方法,Service 不需要为基础 CRUD 重复编写 Mapper 实现。

6.2 链式条件查询

User user = query()
        .eq("phone", phone)
        .one();

query() 创建查询入口,eq 添加手机号等值条件,one 表示期望得到一条记录。这个调用对应“按手机号查询用户”的业务意图,手机号字段应在数据库层保证唯一。

6.3 自动注册用户

User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX
        + RandomUtil.randomString(6));
save(user);

save 使用当前 Service 绑定的 UserMapper 完成落库。登录流程因此形成了“手机号查询,不存在就创建”的闭环。

6.4 分页插件配置

项目通过配置类注册 MyBatis-Plus 分页插件:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(
            new PaginationInnerInterceptor(DbType.MYSQL));
    return interceptor;
}

分页插件不是短信登录必需组件,但它展示了 MyBatis-Plus 的统一插件配置方式。官方文档要求在 MybatisPlusInterceptor 中添加分页拦截器,并指定数据库类型。

七、单拦截器与双拦截器

7.1 单拦截器的职责

Redis 方案的单拦截器可以完成三件事:读取请求头 Token、查询 Redis 用户、判断是否登录。查到用户后写入 UserHolder,查不到则返回 401。

这种实现适合先跑通登录校验,但如果只给受保护路径注册拦截器,用户访问公开接口时不会触发 Token 续期。

7.2 RefreshTokenInterceptor

刷新拦截器注册到 /**,先尝试从 Token 恢复用户:

String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
    return true;
}

Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
        .entries(LOGIN_USER_KEY + token);
if (userMap.isEmpty()) {
    return true;
}

UserDTO userDTO = BeanUtil.fillBeanWithMap(
        userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(
        LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.SECONDS);
return true;

Token 无效时继续放行,由后续登录拦截器决定当前接口是否必须登录。Token 有效时刷新 TTL,实现滑动续期。afterCompletion 中必须清理 UserHolder,避免线程复用造成用户信息串请求。

7.3 LoginInterceptor

登录拦截器只判断 UserHolder 是否有用户:

if (UserHolder.getUser() == null) {
    response.setStatus(401);
    return false;
}
return true;

它只注册到需要登录的路径,并设置为第二个执行。这样两个拦截器的职责分别是“尽量恢复用户并续期”和“对受保护接口强制认证”。

7.4 注册顺序

registry.addInterceptor(
        new RefreshTokenInterceptor(stringRedisTemplate))
        .addPathPatterns("/**")
        .order(0);

registry.addInterceptor(new LoginInterceptor())
        .excludePathPatterns("/user/code", "/user/login")
        .order(1);

先执行刷新拦截器,后执行登录拦截器,登录拦截器才能读取前者写入 ThreadLocal 的用户。

八、功能验证

按以下顺序验证即可:发送验证码、提交手机号和验证码登录、检查 Token、携带 Token 访问 /user/me,再分别测试无 Token 和 Token 过期时访问受保护接口的结果。

九、总结

Session 方案帮助理解短信登录的基本流程,Redis+Token 方案负责解决多实例和多端访问。验证码使用 String,用户信息使用 Hash;MyBatis-Plus 负责用户查询与保存;双拦截器将用户恢复、Token 续期和强制登录校验拆开处理。

Logo

一站式 AI 云服务平台

更多推荐