黑马点评项目的学习日志
项目需要实现的功能介绍
项目架构
前端登录 ,使用nginx启动前端项目 ,然后访问8080端口,必须是在后端项目启动的情况下
数据转换
Bean --- > String : Bean.toString()
String ----> Bean : Shop shop = JSONUtil.toBean(shopJson, Shop.class);
Bean ---->hashMap : Map<String, Object> map1 = BeanUtil.beanToMap(userDTO);
hash ---->Bean : UserDTO userDTO1 = BeanUtil.mapToBean(map, UserDTO.class, true);
2023.2.18—–短信登录
基于session实现发送验证码登录
流程图的分析
发送短信验证码
接口:@PostMapping("/user/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) { // TODO 发送短信验证码并保存验证码 return userService.sendCode(phone,session); }
service层实现流程
@Override public Result sendCode(String phone, HttpSession session) { // TODO 实现发送验证码方法 /** * 1. 校验手机号是否合格 * 2. 不合格怎么做 //合格.......... * 1. 生成验证码 * 2. 保存验证码到 session * 3. 发送验证码 */ //1. 校验手机号是否合格(一般使用正则表达式去校验) 这里我们封装到RefexUtils.isPhoneInvalid是否是无效手机号 boolean isNotNumber = RegexUtils.isPhoneInvalid(phone); //不是手机号 if(isNotNumber){ return Result.fail("手机号格式错误 !!!"); } //合格,是手机号 //1. 生成验证码 String code = RandomUtil.randomNumbers(6); session.setAttribute("code",code); log.debug("发送短信验证码成功 !"); //需要调用阿里云的测试,暂时不是重点 ,无需实现 //返回ok就行了 return Result.ok(); }
短信验证码登录
- 提交验证码和手机号,并且进行判断是否正确
- 正确 : 就继续 ,错误 : 返回验证码错误
- 调用数据库查询用户是否存在
- 存在的话 : 保存用户信息到session,不存在 : 就跳转到注册页面 ,注册并保存到数据库
接口 : @PostMapping("/user/login")
@PostMapping("/login") public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){ // TODO 实现登录功能 return userService.login(loginForm,session); }
service层实现
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { // TODO 实现登录功能 //1. 提交验证码和手机号,并且进行判断是否正确 String phone = (String) session.getAttribute("phone"); String code = (String) session.getAttribute("code"); //2. **正确 : **就继续 ,**错误** : 返回验证码错误 if(!loginForm.getPhone().equals(phone) || !loginForm.getCode().equals(code)){ return Result.fail("手机号/验证码错误!"); } //3. 调用数据库查询用户是否存在 User user = query().eq("phone", phone).one(); //4. **存在的话** : 保存用户信息到session,**不存在** : 就跳转到注册页面 ,注册并保存到数据库 if(user == null){ user = createUserWithPhone(phone); } //保存 session.setAttribute("user",user); return Result.ok(); } private User createUserWithPhone(String phone) { User user = new User(); user.setPhone(phone); //生成随机的用户名 user.setNickName("user_" + RandomUtil.randomString(10)); return user; }
校验登录状态
在拦截器中是实现校验功能
流程
- 获取请求携带的cookie
- 获取用户
- 判断用户是否存在,存在 : 保存该线程 ,**不存在 :**拦截
首先实现拦截器
在拦截器中我们就可以实现我们需要的登录流程
package com.hmdp.config.Handler; import com.hmdp.dto.UserDTO; import com.hmdp.entity.User; import com.hmdp.utils.UserHolder; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; /** * 配置登录校验拦截器 */ public class LoginInterceptor implements HandlerInterceptor { /** * 拦截请求 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //1. 获取请求携带的cookie HttpSession session = request.getSession(); //2. 获取用户 Object user = session.getAttribute("user"); //3. 判断用户是否存在,**存在 :** 保存该线程 ,**不存在 :**拦截 if(user == null){ response.setStatus(401); return false; } //4. 保存信息到ThreadLocal UserHolder.saveUser((UserDTO) user); //放行 return true; } /** * 销毁用户的信息,避免信息的泄露 ,防止内存泄露 */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { //移除用户 ,清除的是线程中的 UserHolder.removeUser(); } }
在mvc配置类中添加拦截器
package com.hmdp.config; import com.hmdp.config.Handler.LoginInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class MvcConfig implements WebMvcConfigurer { /** * 添加拦截器 * @param registry */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) //排除不需要拦截的路径 .excludePathPatterns( "/user/code", "/user/login", "/blog/hot", "/shop/**", "/shop-type/**", "/upload/**", "/voucher/**" ); } }
登录
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { // TODO 实现登录功能 //1. 提交验证码和手机号,并且进行判断是否正确 String phone = loginForm.getPhone(); if(RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号格式错误!"); } String code = (String) session.getAttribute("code"); //2. **正确 : **就继续 ,**错误** : 返回验证码错误 if(phone == null || !loginForm.getCode().equals(code)){ return Result.fail("手机号/验证码错误!"); } //3. 调用数据库查询用户是否存在 User user = query().eq("phone", phone).one(); //4. **存在的话** : 保存用户信息到session,**不存在** : 就跳转到注册页面 ,注册并保存到数据库 if(user == null){ user = createUserWithPhone( phone); } //保存 /** * 因为这样会将所有的用户的信息都传过来,这样不利于保护用户隐私 ,所以我们映射到前端的时候不能将全部的信息都返回 * 仅仅返回一些简单的信息即可,所以我们就用到了UserDTO * 使用Bean.copyProperties就可以将user中的属性的值拷贝一份给userDto * 然后存储到session的就是我们的UserDTo对象 */ UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); session.setAttribute("user",userDTO); return Result.ok(); }
对于返回的信息,因为我们登录时会设置密码等 ,一系列隐私属性,如果我们返回前端这些属性的话,那么势必会造成信息泄露。所以为例用户安全着想 我们映射到前端的时候不能将全部的信息都返回 。 仅仅返回一些简单的信息即可,所以我们就用到了UserDTO 。 使用Bean.copyProperties就可以将user中的属性的值拷贝一份给userDto。 然后存储到session的就是我们的UserDTo对象, 这样就避免用户信息在传入前端时出现信息泄露的风险。
获取当前用户并返回
@GetMapping("/me") public Result me(){ // TODO 获取当前登录的用户并返回 UserDTO user = UserHolder.getUser(); return Result.ok(user); }
集群的Session共享问题
存储的信息是在单线程中的,所以多台Tomcat并不能共享session的存储空间 ,当请求切换到不同的tomcat服务器导致数据丢失的问题 ——session共享问题
当用户第一次进入系统时,tomcat服务器①接收到请求,然后进行处理用户的请求(登录注册等)。
当用户第二次进入系统时 ,被负载均衡到了tomca服务器② 。用户的信息其实是已经注册了的,但是这里却无法获取。导致用户还得注册…这会造成用户体验感很差,所以我们需要继续处理。
这就是Session共享的问题
解决办法:
实现session共享。
Redis实现解决Session共享问题
使用redis代替session解决。
基于Redis实现共享session登录
1.当我们实现生成验证码时 ,就可以直接将以 【手机号为 key : 验证码为value】 保存到reids中
2.输入完验证码,点击登录的时候。进行校验,我们就可以在redis中查询当前手机号保存的value与输入的进行比较。如果比较正确就下一步
3.根据手机号查询是否有这个用户。如果有就保存这个用户的信息到redis ,【以随机的token为key : 用户信息为value】 。
4.如果没有查到,那么就注册新的用户。然后保存到数据库(mysql),然后再回到步骤3进行
5.校验登录状态时, 请求就会携带着token。
6.然后我们就可以通过携带得token得value属性值判断用户是否存在。如果存在,那么就保存用户到ThreadLocal中,然后放行该请求。
7.如果不存在,那么拦截器就会拦截请求
实现
1.发送短信验证码,然后将验证码保存到redis中
//1. 生成验证码 String code = RandomUtil.randomNumbers(6); /**----------------- * 保存验证码到redis中 * 添加业务前缀 * 设置验证码的有效期2分钟 */ stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
@Resource private StringRedisTemplate stringRedisTemplate; @Autowired private UserMapper userMapper; @Override public Result sendCode(String phone, HttpSession session) { // TODO 实现发送验证码方法 /** * 1. 校验手机号是否合格 * 2. 不合格怎么做 //合格.......... * 1. 生成验证码 * 2. 保存验证码到 session * 3. 发送验证码 */ //1. 校验手机号是否合格(一般使用正则表达式去校验) 这里我们封装到RefexUtils.isPhoneInvalid是否是无效手机号 boolean isNotNumber = RegexUtils.isPhoneInvalid(phone); //不是手机号 if(isNotNumber){ return Result.fail("手机号格式错误 !!!"); } //合格,是手机号 //1. 生成验证码 String code = RandomUtil.randomNumbers(6); /**----------------- * 保存验证码到redis中 * 添加业务前缀 * 设置验证码的有效期 2分钟 */ stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES); //----------------- log.debug("发送短信验证码成功 ! 验证码为 : [" + code + "]"); //需要调用阿里云的测试,暂时不是重点 ,无需实现 //返回ok就行了 return Result.ok(); }
2.点击登录的时候。进行校验,我们就可以在redis中查询当前手机号保存的value与输入的进行比较
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { // TODO 实现登录功能 //1. 提交验证码和手机号,并且进行判断是否正确 String phone = loginForm.getPhone(); if(RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号格式错误!"); } String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone); //2. **正确 : **就继续 ,**错误** : 返回验证码错误 if(phone == null || !loginForm.getCode().equals(code)){ return Result.fail("验证码错误!"); } //3. 调用数据库查询用户是否存在 User user = query().eq("phone", phone).one(); //4. **存在的话** : 保存用户信息到session,**不存在** : 就跳转到注册页面 ,注册并保存到数据库 if(user == null){ user = createUserWithPhone( phone); } //保存 /** * 1.保存用户信息到 redis中 * 2. 随机生成token, 作为登录令牌 * 3. 将user对象转成hashmap去存储 * 4. 存储 * 5. 返回token */ String token = UUID.randomUUID().toString(true); UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true). setFieldValueEditor((filedName , fieldValue) -> fieldValue.toString())); //将对象转成map stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map); //存储完成设置有效期 30 min stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL ,TimeUnit.MINUTES); /** * 如果用户30分钟内一直进行访问的话,那么有效期就会不断的变化,所以我么就需要再拦截器中设置,一旦用户点击,就是有了请求 * 那么就重置30分钟,一直往复的设值,那么就实现了用户30分钟不点点击就删除token的设置 */ return Result.ok(token); }
重点1
//保存 /** * 1.保存用户信息到 redis中 * 2. 随机生成token, 作为登录令牌 * 3. 将user对象转成hashmap去存储 * 4. 存储 * 5. 返回token */ String token = UUID.randomUUID().toString(true); UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); //3. 将user对象转成hashmap去存储 Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true) .setFieldValueEditor((filedName , fieldValue) -> fieldValue.toString())); //上面那样写的目的是为了实现bean转换成map,因为我们的redis中的hash结构key 和 value全都是String类型,而在UserDTO中,id属性为Long类型,无法无法强转为String类型,所以就需要我们自定以hash中的存储类型为Long类型 stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map); //存储完成设置有效期 30 min stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL ,TimeUnit.MINUTES); /** * 如果用户30分钟内一直进行访问的话,那么有效期就会不断的变化,所以我么就需要再拦截器中设置,一旦用户点击,就是有了请求 * 那么就重置30分钟,一直往复的设值,那么就实现了用户30分钟不点点击就删除token的设置 */
重点2
重置token的时常,实现30分钟为点击删除token 以及**一旦点击某个请求就重置token的时间**
方法: 新增一个拦截器,只处理点击请求就重置token时间的问题
两个不同作用的拦截器
package com.hmdp.config.Handler; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.StrUtil; import com.hmdp.dto.UserDTO; import com.hmdp.utils.UserHolder; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.Map; import java.util.concurrent.TimeUnit; import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY; import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL; /** * 点击某个请求, 就重置token时间的拦截器 */ public class preHandler implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public preHandler(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //1. 获取请求携带的token String token = request.getHeader("authorization"); String key = LOGIN_USER_KEY+ token; //2. 基于token获取redis中用户 //3. 判断用户是否存在 ,如果不存在直接放行,不进行下面的步骤 if(StrUtil.isBlank(token)){ return true; } Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(key); //将redis查询到的用户信息hashmap转换成user对象 UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false); //4. 保存信息到ThreadLocal UserHolder.saveUser(userDTO); //刷新token有效期 stringRedisTemplate.expire(key,LOGIN_USER_TTL , TimeUnit.MINUTES); //放行 return true; } }
package com.hmdp.config.Handler; import com.hmdp.utils.UserHolder; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * 配置登录校验拦截器 */ public class LoginInterceptor implements HandlerInterceptor { /** * 拦截请求, 仅需要判断是否需要拦截,不需要做其他的事情 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if(UserHolder.getUser() == null){ response.setStatus(401); return false; } //由用户,放行 return true; } }
MVC配置文件中设置拦截器的执行顺序
通过后面的order(x); //x数字越小优先级越高,越先执行
@Configuration public class MvcConfig implements WebMvcConfigurer { @Resource private StringRedisTemplate stringRedisTemplate; /** * 添加拦截器 * @param registry */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) //排除不需要拦截的路径 .excludePathPatterns( "/user/code", "/user/login", "/blog/hot", "/shop/**", "/shop-type/**", "/upload/**", "/voucher/**" ).order(1); //先执行 //拦截所有的请求 ,作用就是用户登录了就点击刷新token消失的时间 registry.addInterceptor(new preHandler(stringRedisTemplate)).addPathPatterns("/**").order(0); } }