导入

数据库

  • 表结构:
    • tb_user:用户基本信息表(用户名、密码等登录信息)
    • tb_user_info:用户详情信息表
    • tb_shop:商户信息表
    • tb_shop_type:商户类型分类表(美食/KTV/丽人等)
    • tb_blog:用户探店日记表(含照片、文字、点赞数)
    • tb_follow:用户关注关系表
    • tb_voucher:优惠券信息表
    • tb_voucher_order:优惠券订单表

项目架构

  • 技术栈:
    • 前端:Nginx部署静态资源
    • 后端:Spring Boot单体架构(非微服务)
    • 数据层:MySQL集群 + Redis集群
  • 扩展性:
    • 支持Tomcat水平扩展形成集群
    • 需解决集群间的Session共享问题
  • 通信流程:
    • 前端通过Nginx获取静态页面
    • 页面通过Ajax请求后端接口
    • 后端从MySQL/Redis获取数据返回

基于session实现登录

image-1

发送短信验证码

  • 请求规范:
    • 方式:POST请求
    • 路径:/user/code
    • 参数:phone(手机号)
    • 返回值:无特定返回值要求
public Result sendCode(String phone, HttpSession session) {  
    // 1. 验证手机号  
    if (RegexUtils.isPhoneInvalid(phone)) {  
        // 验证码不合法  
        return Result.fail("手机号格式错误");  
    }  
  
    // 2. 验证码生成  
    String code = RandomUtil.randomNumbers(6);  
  
    // 3. 保存验证码到session  
    session.setAttribute("code", code);  
  
    // 4. 发送验证码  
    log.debug("发送短信验证码成功,验证码:{}", code);  
  
    return Result.ok();  
}

短信验证码登录和注册

    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.toString().equals(code)) {
            // 验证码错误
            return Result.fail("验证码错误");
        }
        // 3. 根据手机号查询用户
        User user = query().eq("phone", phone).one();
        if (user == null) {
            // 4. 用户不存在,创建新用户
            user = createUserWithPhone(phone);
        }
        // 5. 保存用户信息到session中
        session.setAttribute("user", user);
        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;
    }

用户信息保密

只将必要的user信息保存到session中,一是缓解内存压力,二是保密隐私信息

// 5. 保存用户信息到session中  
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

实现登录校验拦截器

1. MvcConfig.java - MVC配置类

这个配置类实现了Spring MVC的拦截器配置:

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

该配置注册了LoginInterceptor登录拦截器,并排除了一些不需要登录验证的路径:

  • /user/code/user/login:发送验证码和登录接口
  • /blog/hot:热门博客列表
  • /upload/**:上传相关接口
  • /shop-type/**:商店类型相关接口
  • voucher/**:优惠券相关接口
  • /shop/**:商店相关接口

2. LoginInterceptor.java - 登录拦截器

这是一个实现HandlerInterceptor接口的拦截器,用于验证用户是否已登录:

  1. preHandle方法(请求处理前执行):

    • 从session中获取用户信息
    • 如果用户未登录(session中没有user),返回401状态码并拦截请求
    • 如果用户已登录,将用户信息保存到UserHolder中,供后续操作使用
  2. afterCompletion方法(请求完成后执行):

    • 清理UserHolder中的用户信息,防止内存泄漏
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中的用户
        UserDTO user = (UserDTO) session.getAttribute("user");
        // 3. 判断用户是否存在
        if (user == null) {
            // 4. 不存在,拦截,返回401
            response.setStatus(401);
            return false;
        }
        // 5. 存在,保存用户信息,放行
        UserHolder.saveUser(user);
        return true;
    }
 
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}

Tip

这里

// 2. 获取session中的用户
	UserDTO user = (UserDTO) session.getAttribute("user");

是因为前面存储在session中的用户信息修改为了UserDto类型

3. UserHolder.java - 用户信息持有者

使用ThreadLocal存储当前线程的用户信息,实现用户信息在线程内的共享和隔离:

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();  
    }  
}

整体流程

  1. 用户访问需要登录的接口时,LoginInterceptor会拦截请求
  2. 拦截器检查session中是否存在用户信息
  3. 如果用户已登录,将用户信息存入UserHolder
  4. 控制器中可以通过UserHolder.getUser()获取当前登录用户信息
  5. 请求完成后清理UserHolder中的用户信息

这种设计实现了用户登录状态的统一管理,避免了在每个控制器方法中重复检查登录状态的代码。

session共享

image-2

Redis代替session的业务流程

image-3

image-4

验证码key的唯一性

  • session特性:每个浏览器有独立session,不同请求互不干扰
  • Redis特性:共享内存空间,所有请求共用同一个Redis实例
  • key设计:
    • 问题:使用固定key会导致不同手机号的验证码互相覆盖
    • 解决方案:以手机号为key存储验证码
    • 优势:确保唯一性且便于后续验证时获取

用户信息数据类型的选择

  • 可选方案:
    • String结构:将对象序列化为JSON字符串存储
      • 优点:直观易读
      • 缺点:字段耦合,修改需整体替换;内存占用较大
    • Hash结构:将对象字段拆分为field-value对存储
      • 优点:支持字段级CRUD;内存占用更少
      • 缺点:结构相对复杂
  • 推荐方案:优先选择Hash结构,特别在数据量大或需要频繁修改字段时

用户信息key的选择

  • key要求:
    • 唯一性:确保不同用户数据不会冲突
    • 可携带性:客户端能方便携带该key进行后续请求
  • 推荐方案:
    • 避免使用手机号:防止敏感信息泄露
    • 使用随机token:如UUID生成的随机字符串作为key
  • 凭证传递:
    • 服务端:生成token后返回给客户端
    • 客户端:保存token并在后续请求中携带
    • 校验流程:服务端通过token从Redis获取用户信息进行校验

前端代码分析

  login(){
	if(!this.radio){
	  this.$message.error("请先确认阅读用户协议!");
	  return
	}
	if(!this.form.phone || !this.form.code){
	  this.$message.error("手机号和验证码不能为空!");
	  return
	}
	axios.post("/user/login", this.form)
	.then(({data}) => {
		if(data){
		  // 保存用户信息到session
		  sessionStorage.setItem("token", data);
		}
		// 跳转到首页
		location.href = "/index.html"
	})
	.catch(err => this.$message.error(err))
  }
// request拦截器,将用户token放入头中
let token = sessionStorage.getItem("token");
axios.interceptors.request.use(
  config => {
    if(token) config.headers['authorization'] = token
    return config
  },
  error => {
    console.log(error)
    return Promise.reject(error)
  }
)
  • token保存:
    • 存储位置:使用sessionStorage保存服务端返回的token
    • 持久性:sessionStorage在浏览器会话期间有效
  • 请求携带:
    • 拦截器机制:通过axios拦截器自动为每个请求添加Authorization头
    • 头信息格式:Authorization头携带token值
  • 安全考虑:
    • 避免敏感信息:不使用手机号等敏感信息作为key
    • 传输安全:通过HTTPS等安全协议传输token

代码实现

UserServiceImpl.java的变更

  • 添加了Redis相关的工具类和常量引用
  • 验证码存储方式从Session改为Redis:
    • 原先:session.setAttribute("code", code)
    • 现在:stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES)
  • 用户登录信息存储方式从Session改为Redis:
    • 生成随机token作为用户身份标识
    • 将User对象转换为Map存储到Redis的Hash结构中
    • 设置token的有效期(36000分钟)
    • 登录成功后返回token给前端
@Resource  
private StringRedisTemplate stringRedisTemplate;  
  
/**  
 * 发送验证码并保存  
 *  
 * @param phone  
 * @param session  
 * @return  
 */@Override  
public Result sendCode(String phone, HttpSession session) {  
    // 1. 验证手机号  
    if (RegexUtils.isPhoneInvalid(phone)) {  
        // 验证码不合法  
        return Result.fail("手机号格式错误");  
    }  
  
    // 2. 验证码生成  
    String code = RandomUtil.randomNumbers(6);  
  
    // 3. 保存验证码到redis  
    stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);  
  
    // 4. 发送验证码  
    log.debug("发送短信验证码成功,验证码:{}", code);  
  
    return Result.ok();  
}
 
public Result login(LoginFormDTO loginForm, HttpSession session) {  
    String phone = loginForm.getPhone();  
    // 1. 验证手机号  
    if (RegexUtils.isPhoneInvalid(phone)) {  
        // 验证码不合法  
        return Result.fail("手机号格式错误");  
    }  
  
    // 2. 从redis获取验证码并校验  
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);  
    String code = loginForm.getCode();  
    if (cacheCode == null || !cacheCode.equals(code)) {  
        // 验证码错误  
        return Result.fail("验证码错误");  
    }  
    // 3. 根据手机号查询用户  
    User user = query().eq("phone", phone).one();  
    if (user == null) {  
        // 4. 用户不存在,创建新用户  
        user = createUserWithPhone(phone);  
    }  
    // 5. 保存用户信息到redis中  
    // 5.1 随机生成token,作为登录令牌  
    String token = UUID.randomUUID().toString(true);  
    // 5.2 将User对象转为HashMap存储  
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);  
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),  
            CopyOptions.create()  
                    .setIgnoreNullValue(true)  
                    .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));  
    // 5.3 存储  
    String tokenKey = RedisConstants.LOGIN_USER_KEY + token;  
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);  
    // 5.4 设置token有效期  
    stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);  
    // 6. 返回token  
    return Result.ok(token);  
}

MvcConfig.java 的变更

  • 在MvcConfig中注入了StringRedisTemplate依赖
  • 修改LoginInterceptor的构造方式,将StringRedisTemplate传递给拦截器
  • 这样使拦截器能够访问Redis来验证用户token
@Configuration  
public class MvcConfig implements WebMvcConfigurer {  
    @Resource  
    private StringRedisTemplate stringRedisTemplate;  
    @Override  
    public void addInterceptors(InterceptorRegistry registry) {  
        registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))  
                .excludePathPatterns(  
                        "/user/code",  
                        "/user/login",  
                        "/blog/hot",  
                        "/upload/**",  
                        "/shop-type/**",  
                        "voucher/**",  
                        "/shop/**"  
                );  
    }  
}

LoginInterceptor.java 的变更

  • 重构了用户身份验证逻辑:
    • 原先:从Session中获取用户信息
    • 现在:从请求头获取token,然后从Redis中获取用户信息
  • 添加了token有效期自动刷新机制
  • 实现了无状态认证,便于分布式部署

Warning

LoginInterceptor类实例在MvcConfig类中手动创建,不能使用@Resource,@AutoWired注解自动注入,因为其实例不是Spring容器中的实例

  
public class LoginInterceptor implements HandlerInterceptor {  
    private StringRedisTemplate stringRedisTemplate;  
  
    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {  
        this.stringRedisTemplate = stringRedisTemplate;  
    }  
  
    @Override  
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {  
  
        // 1. 获取请求头中的token  
        String token = request.getHeader("authorization");  
        if (StrUtil.isBlank(token)) {  
            // 不存在,拦截,返回401  
            response.setStatus(401);  
            return false;  
        }  
        // 2. 基于token获取redis中的用户  
        String key = LOGIN_USER_KEY + token;  
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);  
        // 3. 判断用户是否存在  
        if (userMap.isEmpty()) {  
            // 4. 不存在,拦截,返回401  
            response.setStatus(401);  
            return false;  
        }  
        // 5.将查询到的用户信息转为UserDTO  
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);  
        // 6. 存在,保存用户信息  
        UserHolder.saveUser(userDTO);  
        // 7.刷新token有效期  
        stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);  
        // 8.放行  
        return true;  
    }  
  
    @Override  
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {  
        // 移除用户  
        UserHolder.removeUser();  
    }  
}

工作流程

  1. 用户请求验证码 验证码存储在Redis中,设置2分钟有效期
  2. 用户登录 验证Redis中的验证码 验证通过后生成token,用户信息存储到Redis,返回token给前端
  3. 用户后续请求 请求头携带token 拦截器从Redis获取用户信息 刷新token有效期

这次改动实现了现代化的无状态认证机制,为系统扩展性和安全性提供了更好的支持。

拦截器的优化

image-6

Note

之前校验登录的拦截器只拦截那些需要登录权限的接口,这导致用户访问其它接口时后端不会刷新token,因此需要新建一个拦截一切路径的拦截器,在其中刷新token

LoginInterceptor.java

  • 简化了逻辑,专门负责检查用户是否已登录
  • 通过UserHolder中的用户信息判断是否需要拦截
  • 如果未登录则返回401状态码
public class LoginInterceptor implements HandlerInterceptor {  
    @Override  
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {  
        // 判断是否需要拦截(ThreadLocal中是否有用户)  
        if (UserHolder.getUser() == null) {  
            // 没有,需要拦截,返回401  
            response.setStatus(401);  
            return false;  
        }  
        // 有用户,放行  
        return true;  
    }  
}

RefreshTokenInterceptor.java

  • 负责从请求头中获取token并验证用户身份
  • 如果用户存在,则刷新token的有效期
  • 这个拦截器对所有路径生效
  
public class RefreshTokenInterceptor implements HandlerInterceptor {  
    // 该类手动创建,不能使用@Resource,@AutoWired注解自动注入,因为本类的实例不是Spring容器中的实例  
    private StringRedisTemplate stringRedisTemplate;  
  
    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {  
        this.stringRedisTemplate = stringRedisTemplate;  
    }  
  
    @Override  
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {  
  
        // 1. 获取请求头中的token  
        String token = request.getHeader("authorization");  
        if (StrUtil.isBlank(token)) {  
            return true;  
        }  
        // 2. 基于token获取redis中的用户  
        String key = LOGIN_USER_KEY + token;  
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);  
        // 3. 判断用户是否存在  
        if (userMap.isEmpty()) {  
            return true;  
        }  
        // 5.将查询到的用户信息转为UserDTO  
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);  
        // 6. 存在,保存用户信息  
        UserHolder.saveUser(userDTO);  
        // 7.刷新token有效期  
        stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);  
        // 8.放行  
        return true;  
    }  
  
    @Override  
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {  
        // 移除用户  
        UserHolder.removeUser();  
    }  
}

MvcConfig.java

  • 移除了LoginInterceptor中的StringRedisTemplate依赖
  • 配置了两个拦截器的优先级,RefreshTokenInterceptor优先级更高(order(0))
  • LoginInterceptor只对需要登录的接口进行拦截
@Configuration  
public class MvcConfig implements WebMvcConfigurer {  
    @Resource  
    private StringRedisTemplate stringRedisTemplate;  
    @Override  
    public void addInterceptors(InterceptorRegistry registry) {  
        // 登录拦截器  
        registry.addInterceptor(new LoginInterceptor())  
                .excludePathPatterns(  
                        "/user/code",  
                        "/user/login",  
                        "/blog/hot",  
                        "/upload/**",  
                        "/shop-type/**",  
                        "voucher/**",  
                        "/shop/**"  
                ).order(1);  
        // 刷新token拦截器  
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))  
                .addPathPatterns("/**").order(0);  
    }  
}