点评中是如何实现短信登录的
点评中是如何实现短信登录的
首先在这个项目中 我们主要还是通过session来实现的验证码登录
根据这个图片我们很清楚的知道 首先要制造一个随机的验证码以确保并且保存到redis中(等下我会说为什么不存在session中)接下来前端传来验证码 后端校验后 判断是否为新用户 如果是新用户 那么注册一个用户 将昵称设置为随机字段 手机号为唯一字段 如果存在 就保存在redis中 返回随机token给客户端 接下来前端传来手机号 我们从redis中拿到用户信息 保存到Threadlocal中 放行请求
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Resourceprivate StringRedisTemplate stringRedisTemplate;private static final String LOGIN_CODE_KEY = "login:code:";private static final Long LOGIN_CODE_TTL = 2L;/*** 发送手机验证码*/public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {// 发送短信验证码并保存验证码if (RegexUtils.isEmailInvalid(phone)) {return Result.fail("邮箱格式不正确");}String code = MailUtils.achieveCode();// 将验证码存入Redis,设置有效期为2分钟stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);log.info("发送登录验证码:{}", code);try {MailUtils.sendTestMail(phone, code);} catch (MessagingException e) {throw new RuntimeException(e);}return Result.ok();}@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {// 1.校验手机号String phone = loginForm.getPhone();if (RegexUtils.isEmailInvalid(phone)) {return Result.fail("邮箱格式不正确");}//2. 校验验证码String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);String code = loginForm.getCode();log.info("登录校验 - 手机号: {}, 输入的验证码: {}, 缓存的验证码: {}", phone, code, cacheCode);if (cacheCode == null || !cacheCode.equals(code)) {// 不一致,报错return Result.fail("验证码错误");}//3. 验证通过后删除验证码stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);//4. 根据手机号查询用户User user = query().eq("phone", phone).one();//5. 判断用户是否存在if (user == null) {// 6.不存在,创建新用户并保存user = createUserWithPhone(phone);}//7.存在,保存用户信息到redis中UserDTO userDTO = new UserDTO();BeanUtils.copyProperties(user, userDTO);session.setAttribute("user", userDTO);// 将对象中字段全部转成string类型,StringRedisTemplate只能存字符串类型的数据Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));String token = UUID.randomUUID().toString();String tokenKey = LOGIN_USER_KEY + token;stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);//8.返回tokenreturn Result.ok(token);}private User createUserWithPhone(String phone) {User user = new User();// 如果是邮箱,生成一个随机的手机号作为唯一标识if (phone.contains("@")) {// 生成一个随机的11位数字作为phoneString randomPhone;User exist;do {// 生成13开头的11位随机手机号randomPhone = "13" + RandomUtil.randomNumbers(9);// 检查该手机号是否已存在exist = query().eq("phone", randomPhone).one();} while (exist != null); // 如果存在,继续生成直到找到一个不存在的user.setPhone(randomPhone);// 可以考虑将邮箱保存到其他字段,如果有需要的话} else {user.setPhone(phone);}user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));save(user);return user;}
}
redis的优点
首先我们来看session会出现的问题
什么是Session集群共享问题?
在分布式集群环境中,会话(Session)共享是一个常见的挑战。默认情况下,Web 应用程序的会话是保存在单个服务器上的,当请求不经过该服务器时,会话信息无法被访问。
Session集群共享问题造成哪些问题?
服务器之间无法实现会话状态的共享。比如:在当前这个服务器上用户已经完成了登录,Session中存储了用户的信息,能够判断用户已登录,但是在另一个服务器的Session中没有用户信息,无法调用显示没有登录的服务器上的服务
如何解决Session集群共享问题?
方案一:Session拷贝(不推荐)
Tomcat提供了Session拷贝功能,通过配置Tomcat可以实现Session的拷贝,但是这会增加服务器的额外内存开销,同时会带来数据一致性问题
方案二:Redis缓存(推荐)
Redis缓存具有Session存储一样的特点,基于内存、存储结构可以是key-value结构、数据共享
Redis缓存相较于传统Session存储的优点:
- 高性能和可伸缩性:Redis 是一个内存数据库,具有快速的读写能力。相比于传统的 Session 存储方式,将会话数据存储在 Redis 中可以大大提高读写速度和处理能力。此外,Redis 还支持集群和分片技术,可以实现水平扩展,处理大规模的并发请求。
- 可靠性和持久性:Redis 提供了持久化机制,可以将内存中的数据定期或异步地写入磁盘,以保证数据的持久性。这样即使发生服务器崩溃或重启,会话数据也可以被恢复。
丰富的数据结构:Redis 不仅仅是一个键值存储数据库,它还支持多种数据结构,如字符串、列表、哈希、集合和有序集合等。这些数据结构的灵活性使得可以更方便地存储和操作复杂的会话数据。 - 分布式缓存功能:Redis 作为一个高效的缓存解决方案,可以用于缓存会话数据,减轻后端服务器的负载。与传统的 Session 存储方式相比,使用 Redis 缓存会话数据可以大幅提高系统的性能和可扩展性。
- 可用性和可部署性:Redis 是一个强大而成熟的开源工具,有丰富的社区支持和活跃的开发者社区。它可以轻松地与各种编程语言和框架集成,并且可以在多个操作系统上运行。
PS:但是Redis费钱,而且增加了系统的复杂度
从前面的分析来看,显然Redis是要优于Session的,但是Redis中有很多数据结构,我们应该选择哪种数据结构来存储用户信息才能够更优呢?可能大多数同学都会想到使用 String 类型的数据据结构,但是这里我推荐使用 Hash结构!
Hash 结构与 String 结构类型的比较:
1 . String 数据结构是以 JSON 字符串的形式保存,更加直观,操作也更加简单,但是 JSON 结构会有很多非必须的内存开销,比如双引号、大括号,内存占用比 Hash 更高
2. Hash 数据结构是以 Hash 表的形式保存,可以对单个字段进行CRUD,更加灵活
Redis替代Session需要考虑的问题:
- 选择合适的数据结构,了解 Hash 比 String 的区别
- 选择合适的key,为key设置一个业务前缀,方便区分和分组,为key拼接一个UUID,避免key冲突防止数据覆盖
- 选择合适的存储粒度,对于验证码这类数据,一般设置TTL为3min即可,防止大量缓存数据的堆积,而对于用户信息这类数据可以稍微设置长一点,比如30min,防止频繁对Redis进行IO操作
单独配置一个拦截器用户刷新Redis中的token:在基于Session实现短信验证码登录时,我们只配置了一个拦截器,这里需要另外再配置一个拦截器专门用户刷新存入Redis中的 token,因为我们现在改用Redis了,为了防止用户在操作网站时突然由于Redis中的 token 过期,导致直接退出网站,严重影响用户体验。那为什么不把刷新的操作放到一个拦截器中呢,因为之前的那个拦截器只是用来拦截一些需要进行登录校验的请求,对于哪些不需要登录校验的请求是不会走拦截器的,刷新操作显然是要针对所有请求比较合理,所以单独创建一个拦截器拦截一切请求,刷新Redis中的Key
登录拦截器:
public class LoginInterceptor implements HandlerInterceptor {/*** 前置拦截器,用于判断用户是否登录*/@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 判断当前用户是否已登录if (ThreadLocalUtls.getUser() == null){// 当前用户未登录,直接拦截response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);return false;}// 用户存在,直接放行return true;}
}
public class RefreshTokenInterceptor implements HandlerInterceptor {// new出来的对象是无法直接注入IOC容器的(LoginInterceptor是直接new出来的)// 所以这里需要再配置类中注入,然后通过构造器传入到当前类中private StringRedisTemplate stringRedisTemplate;public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1、获取token,并判断token是否存在String token = request.getHeader("authorization");if (StrUtil.isBlank(token)){// token不存在,说明当前用户未登录,不需要刷新直接放行return true;}// 2、判断用户是否存在String tokenKey = LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);if (userMap.isEmpty()){// 用户不存在,说明当前用户未登录,不需要刷新直接放行return true;}// 3、用户存在,则将用户信息保存到ThreadLocal中,方便后续逻辑处理,比如:方便获取和使用用户信息,Redis获取用户信息是具有侵入性的UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);ThreadLocalUtls.saveUser(BeanUtil.copyProperties(userMap, UserDTO.class));// 4、刷新token有效期stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}
}
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {// new出来的对象是无法直接注入IOC容器的(LoginInterceptor是直接new出来的)// 所以这里需要再配置类中注入,然后通过构造器传入到当前类中@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 添加登录拦截器registry.addInterceptor(new LoginInterceptor())// 设置放行请求.excludePathPatterns("/user/code","/user/login","/blog/hot","/shop/**","/shop-type/**","/upload/**","/voucher/**").order(1); // 优先级默认都是0,值越大优先级越低// 添加刷新token的拦截器registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);}
}
需要注意的是 我这里用到的是qq邮箱来发验证码 所以还需要配置qq邮箱的工具类
package com.hmdp.utils;import javax.mail.*;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMessage.RecipientType;
import java.util.*;public class MailUtils {public static void main(String[] args) throws MessagingException {sendTestMail("收件人邮箱@qq.com", new MailUtils().achieveCode());}public static void sendTestMail(String email, String code) throws MessagingException {Properties props = new Properties();props.put("mail.smtp.auth", "true");props.put("mail.smtp.host", "smtp.qq.com");props.put("mail.smtp.port", "465");props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");props.put("mail.smtp.socketFactory.port", "465");props.put("mail.smtp.socketFactory.fallback", "false");props.put("mail.user", "198733702@qq.com");props.put("mail.password", "lvxzwwixjiewcbcf");Authenticator authenticator = new Authenticator() {protected PasswordAuthentication getPasswordAuthentication() {String userName = props.getProperty("mail.user");String password = props.getProperty("mail.password");return new PasswordAuthentication(userName, password);}};Session mailSession = Session.getInstance(props, authenticator);MimeMessage message = new MimeMessage(mailSession);InternetAddress from = new InternetAddress(props.getProperty("mail.user"));message.setFrom(from);InternetAddress to = new InternetAddress(email);message.setRecipient(RecipientType.TO, to);message.setSubject("Kyle's Blog 邮件测试");message.setContent("尊敬的用户:你好!\n注册验证码为:" + code + "(有效期为一分钟,请勿告知他人)", "text/html;charset=UTF-8");Transport.send(message);}public static String achieveCode() {String[] beforeShuffle = new String[]{"2", "3", "4", "5", "6", "7", "8", "9","A", "B", "C", "D", "E", "F", "G", "H","I", "J", "K", "L", "M", "N", "P", "Q","R", "S", "T", "U", "V", "W", "X", "Y", "Z","a", "b", "c", "d", "e", "f", "g", "h","i", "j", "k", "l", "m", "n", "p", "q","r", "s", "t", "u", "v", "w", "x", "y", "z"};List<String> list = Arrays.asList(beforeShuffle);Collections.shuffle(list);StringBuilder sb = new StringBuilder();for (String s : list) {sb.append(s);}return sb.substring(3, 8);}
}
这段代码不仅配置了工具类 还写了随机生成验证码的方法
mail:host: smtp.qq.comport: 465username: 198733702@qq.compassword: xprotocol: smtps
不要忘记配置qq邮箱的yml文件