项目实战-黑马点评
项目架构

短信登录

发送短信验证码
实现思路就是按照上图左一部分,
实现类如下
java
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
/**
* 验证手机号发送验证码
*
* @param phone
* @param session
* @return
*/
@Override
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);
//返回ok
return Result.ok();
}
}
这里封装了RegexUtils
工具类,调用了反向验证手机号方法,我们可以学习一下这个工具类的实现
java
public class RegexUtils {
/**
* 是否是无效手机格式
* @param phone 要校验的手机号
* @return true:符合,false:不符合
*/
public static boolean isPhoneInvalid(String phone){
return mismatch(phone, RegexPatterns.PHONE_REGEX);
}
/**
* 是否是无效邮箱格式
* @param email 要校验的邮箱
* @return true:符合,false:不符合
*/
public static boolean isEmailInvalid(String email){
return mismatch(email, RegexPatterns.EMAIL_REGEX);
}
/**
* 是否是无效验证码格式
* @param code 要校验的验证码
* @return true:符合,false:不符合
*/
public static boolean isCodeInvalid(String code){
return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
}
// 校验是否不符合正则格式
private static boolean mismatch(String str, String regex){
if (StrUtil.isBlank(str)) {
return true;
}
return !str.matches(regex);
}
}
其实就是先检查手机号是否为空,如果不为空再把当前手机号字符串按照正则表达式匹配。
短信验证码登录

校验手机号,校验验证码,如果不一致直接返回错误信息。
如果一致,需要查询用户,如果根据当前手机号,用户不存在,那么创建新用户,其实就是insert
。
这里没有Mapper
,MyBatis-Plus
是MyBatis
的增强工具,内置了大量的方法,无需XML就能完成CRUD
java
/**
* 实现登录功能
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//手机号不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.equals(code)){
//3.不一致,报错
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if(user == null){
//6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
//7.保存用户信息到session
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
private User createUserWithPhone(String phone){
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
save(user);
return user;
}
登录校验
在访问后端的多个接口的时候,不可能每次访问都得登陆,保证只要登陆一次即可。于是可以使用拦截器统一拦截,获取session信息,如果存在那么放行,并把信息保存到ThreadLocal
,保证可以随时调用。如果不存在,那么拦截。
java
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取session
HttpSession session = request.getSession();
//2.获取session的用户
Object user = session.getAttribute("user");
//3.判断用户是否存在
if(user == null){
//4.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO) user);
//6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
集群的session共享问题

由于多台Tomcat并不共享session的共享空间,请求切换到不同的Tomcat时就会导致数据丢失。
最开始考虑的是,只要把数据拷贝一份到每个Tomcat即可,但会导致空间浪费问题,因为保存的都是相同数据。
替代方案应满足:
- 数据共享
- 内存存储
- key、value结构
显然可以借助Redis
基于Redis实现共享session登录

发送验证码
改动的地方就是本来储存在session中,现在把验证码保存到Redis中,使用的key是业务+手机号,从而保证唯一性。
java
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 验证手机号发送验证码
*
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//2.如果不符合,可以返回错误信息
return Result.fail("手机号格式错误");
}
//3.生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到session set key value ex 120
stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
//5.发送验证码
log.debug("发送验证码成功,验证码为:{}",code);
//返回ok
return Result.ok();
}
实现登录功能
更新部分在于,取数据从redis中获取,生成的随机token作为令牌和储存用户信息的key。
java
/**
* 实现登录功能
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
//1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//手机号不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//2.校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
//3.不一致,报错
return Result.fail("验证码错误");
}
//4.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if (user == null) {
//6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
//7.保存用户信息到redis
//7.1 随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
//7.2 将user对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.ignoreNullValue()
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
//7.3 储存
String userKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(userKey, userMap);
//7.4 设置token有效期
stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
//8.返回token
return Result.ok(token);
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
save(user);
return user;
}
登录拦截器的优化
我们当前的拦截器存在一个问题,就是拦截了需要登录的请求时,为了避免永久数据对redis的压力,我们在执行登陆后,一般会给token设置有效期 ,意思是长时间不点击访问页面,那么token就会过期,不再允许访问。但是现在只有一个拦截器,拦截的是需要登录的路径 ,而且只有在用户登录时会更新token有效期,这样就会导致即使用户在不停操作,但是不需要登录操作的部分功能也不会更新token有效期,同时需要登陆操作的部分也不会更新token有效期。
怎么解决呢?
我们可以定义两个拦截器,一个用来拦截所有路径,但是不做"拦截"处理,主要负责获取token、查询Redis用户、保存到ThreadLocal、更新token有效期、放行。另外一个拦截需要登陆的路径,查询ThreadLocal用户,不存在就拦截,存在则放行。
