项目环境搭建

数据库初始化

在项目启动前,需要先完成数据库的准备工作:

-- 导入项目所需的SQL表结构及基础数据
-- 主要数据表包括:
-- tb_user: 用户基础信息表
-- tb_user_info: 用户详细信息表  
-- tb_shop: 商户信息表
-- tb_shop_type: 商户分类表
-- tb_blog: 用户博客表
-- tb_follow: 用户关注关系表
-- tb_voucher: 优惠券信息表
-- tb_voucher_order: 优惠券订单表

后端服务部署

将项目源码导入开发环境后,需要进行以下配置:

文件位置:src/main/resources/application.yaml

server:  
  port: 8981  
  
spring:  
  datasource:  
    url: jdbc:mysql://localhost:3306/<数据库名>?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai  
    username: root  
    password: "自己的密码"  
  redis:  
    host: localhost  
    port: 6379  
    password: 
    database: 8  
    lettuce:  
      pool:  
        max-active: 10  
        max-idle: 10  
        min-idle: 1  
        time-between-eviction-runs: 10s  
  
mybatis-plus:  
  type-aliases-package: com.hmdp.entity  
  
logging:  
  level:  
    com.hmdp: debug

完成配置后启动项目,通过访问 http://localhost:8981/shop-type/list 验证服务状态。

前端服务部署

前端基于Nginx进行部署:

  1. 解压Nginx安装包至目标目录
  2. 修改conf目录下的端口配置为8971
  3. 启动Nginx服务
  4. 访问 http://localhost:8971 即可使用系统

登录功能实现

基于Session的登录方案

需求分析与设计

功能流程设计:

  • 短信验证码发送:接收手机号→校验格式→生成验证码→Session存储→发送短信
  • 用户登录注册:校验验证码→查询用户→自动注册→Session保存用户信息
  • 登录状态校验:拦截请求→Session验证→ThreadLocal存储→权限控制

技术架构:

  • 使用HttpSession进行状态管理
  • 通过拦截器实现登录校验
  • 基于ThreadLocal实现用户信息线程内共享

代码开发

1. 验证码发送功能

文件位置:com.hmdp.controller.UserController

实现逻辑:接收前端传递的手机号参数,调用Service层进行验证码发送

/**
 * 用户认证控制器
 */
@RestController
@RequestMapping("/user")
public class UserController {

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

文件位置:com.hmdp.service.impl.UserServiceImpl

实现逻辑:校验手机号格式→生成随机验证码→保存至Session→模拟发送验证码

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

    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1. 校验手机号格式
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式错误");
        }
        
        // 2. 生成6位随机验证码
        String code = RandomUtil.randomNumbers(6);
        
        // 3. 保存验证码至Session,设置有效期
        session.setAttribute("code", code);
        
        // 4. 模拟发送验证码(生产环境应接入短信服务)
        log.debug("生成验证码:{}", code);
        
        return Result.ok();
    }
}

2. 用户登录功能

文件位置:com.hmdp.controller.UserController

实现逻辑:接收登录表单数据,调用Service层进行登录验证和用户注册

/**
 * 用户登录接口
 */
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
    return userService.login(loginForm, session);
}

文件位置:com.hmdp.service.impl.UserServiceImpl

实现逻辑:校验手机号和验证码→查询用户→自动注册新用户→保存用户信息至Session

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1. 参数校验
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误");
    }
    
    // 2. 验证码校验
    Object cacheCode = session.getAttribute("code");
    String code = loginForm.getCode();
    if (!code.equals(cacheCode)) {
        return Result.fail("验证码错误");
    }
    
    // 3. 用户查询与自动注册
    User user = query().eq("phone", phone).one();
    if (user == null) {
        user = createUserWithPhone(phone);
    }
    
    // 4. 用户信息保存至Session
    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));
    save(user);
    return user;
}

3. 登录状态拦截器

文件位置:com.hmdp.config.LoginInterceptor

实现逻辑:拦截需要登录的请求→检查Session中用户信息→保存用户信息至ThreadLocal

public class LoginInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                           HttpServletResponse response, 
                           Object handler) throws Exception {
        // 1. 获取Session中的用户信息
        HttpSession session = request.getSession();
        Object user = session.getAttribute("user");
        
        // 2. 用户不存在则拦截请求
        if (user == null) {
            response.setStatus(401);
            return false;
        }
        
        // 3. 用户存在则保存至ThreadLocal
        UserHolder.saveUser((UserDTO) user);
        
        return true;
    }
}

文件位置:com.hmdp.config.MvcConfig

实现逻辑:配置拦截器规则,排除不需要登录验证的路径

@Configuration
public class MvcConfig implements WebMvcConfigurer {

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

4. 用户信息获取接口

文件位置:com.hmdp.controller.UserController

实现逻辑:从ThreadLocal中获取当前登录用户信息并返回

@GetMapping("/me")
public Result me() {
    // 从ThreadLocal中获取当前登录用户
    UserDTO user = UserHolder.getUser();
    return Result.ok(user);
}

代码完善与问题解决

集群环境Session共享问题

问题分析:
在分布式部署环境下,多台Tomcat服务器的Session存储空间相互独立,导致用户请求在不同服务器间跳转时出现数据丢失。

解决方案:
使用Redis作为分布式缓存替代Session,实现用户状态共享。

基于Redis的分布式登录方案

需求分析与设计

架构改进点:

  • 验证码存储:手机号作为Key,验证码作为Value,设置过期时间
  • 用户信息存储:Token机制替代Session,支持分布式环境
  • 登录状态续期:基于访问行为的自动续期机制

存储结构设计:

  • 验证码:LOGIN:CODE:{phone} -> code
  • 用户信息:LOGIN:TOKEN:{token} -> Hash(userInfo)

代码开发

1. Redis验证码发送

文件位置:com.hmdp.service.impl.UserServiceImplsendCode 方法

实现逻辑:将验证码存储从Session改为Redis,设置2分钟过期时间

@Override
public Result sendCode(String phone, HttpSession session) {
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误");
    }
    
    String code = RandomUtil.randomNumbers(6);
    
    // 保存验证码至Redis,有效期2分钟
    stringRedisTemplate.opsForValue()
        .set(RedisConstants.LOGIN_CODE_KEY + phone, 
             code, 
             RedisConstants.LOGIN_CODE_TTL, 
             TimeUnit.MINUTES);
    
    log.debug("发送验证码:{}", code);
    return Result.ok();
}

2. Redis用户登录

文件位置:com.hmdp.service.impl.UserServiceImpllogin 方法

实现逻辑:生成唯一Token→用户信息转为Hash结构→存储至Redis→返回Token给客户端

@Override
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(RedisConstants.LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    
    if (Objects.isNull(code) || Objects.isNull(cacheCode) || !code.equals(cacheCode)) {
        return Result.fail("验证码错误");
    }
    
    // 3. 用户查询与注册
    User user = query().eq("phone", phone).one();
    if (user == null) {
        user = createUserWithPhone(phone);
    }
    
    // 4. 生成Token并保存用户信息至Redis
    String token = UUID.randomUUID().toString();
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    
    // 用户信息转换为Hash存储
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, 
        new HashMap<>(), 
        CopyOptions.create()
            .setIgnoreNullValue(true)
            .setFieldValueEditor((fieldName, fieldValue) -> 
                fieldValue == null ? "" : fieldValue.toString()));
    
    String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    stringRedisTemplate.expire(tokenKey, 
        RedisConstants.LOGIN_USER_TTL, 
        TimeUnit.MINUTES);
    
    // 5. 返回Token给客户端
    return Result.ok(Collections.singletonMap("token", token));
}

3. 令牌刷新拦截器

文件位置:com.hmdp.config.RefreshTokenInterceptor

实现逻辑:拦截所有请求→检查Token有效性→刷新Token过期时间→保存用户信息至ThreadLocal

public class RefreshTokenInterceptor implements HandlerInterceptor {

    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 = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        
        if (userMap.isEmpty()) {
            return true; // Token无效仍放行,由登录拦截器处理
        }
        
        // 3. 用户信息保存至ThreadLocal
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        UserHolder.saveUser(userDTO);
        
        // 4. 刷新Token有效期
        stringRedisTemplate.expire(key, 
            RedisConstants.LOGIN_USER_TTL, 
            TimeUnit.MINUTES);
            
        return true;
    }
}

4. 拦截器配置优化

文件位置:com.hmdp.config.MvcConfig

实现逻辑:配置双拦截器链,RefreshTokenInterceptor处理所有请求,LoginInterceptor处理需要登录的请求

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 令牌刷新拦截器:优先级高,拦截所有路径
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .addPathPatterns("/**").order(0);
                
        // 登录校验拦截器:优先级低,排除无需登录路径
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                    "/user/login",
                    "/user/code",
                    "/shop/**",
                    "/voucher/**", 
                    "/shop-type/**",
                    "/upload/**"
                ).order(1);
    }
}

新增功能:登录日志记录

为了增强系统的可观测性,我们新增登录日志记录功能,用于记录用户登录行为和异常情况。

文件位置:com.hmdp.service.impl.UserServiceImpllogin 方法新增日志记录

实现逻辑:在登录成功和失败的关键节点记录日志,便于问题排查和用户行为分析

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    String phone = loginForm.getPhone();
    
    // 记录登录尝试
    log.info("用户登录尝试,手机号:{}", phone);
    
    // 1. 参数校验
    if (RegexUtils.isPhoneInvalid(phone)) {
        log.warn("手机号格式错误:{}", phone);
        return Result.fail("手机号格式错误");
    }
    
    // 2. Redis验证码校验
    String cacheCode = stringRedisTemplate.opsForValue()
        .get(RedisConstants.LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    
    if (Objects.isNull(code) || Objects.isNull(cacheCode) || !code.equals(cacheCode)) {
        log.warn("验证码错误,手机号:{},输入验证码:{},期望验证码:{}", 
                phone, code, cacheCode);
        return Result.fail("验证码错误");
    }
    
    // 3. 用户查询与注册
    User user = query().eq("phone", phone).one();
    if (user == null) {
        log.info("新用户注册,手机号:{}", phone);
        user = createUserWithPhone(phone);
    } else {
        log.info("用户登录成功,用户ID:{},手机号:{}", user.getId(), phone);
    }
    
    // 4. 生成Token并保存用户信息至Redis
    String token = UUID.randomUUID().toString();
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, 
        new HashMap<>(), 
        CopyOptions.create()
            .setIgnoreNullValue(true)
            .setFieldValueEditor((fieldName, fieldValue) -> 
                fieldValue == null ? "" : fieldValue.toString()));
    
    String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    stringRedisTemplate.expire(tokenKey, 
        RedisConstants.LOGIN_USER_TTL, 
        TimeUnit.MINUTES);
    
    // 记录Token生成
    log.debug("为用户ID:{} 生成Token:{}", user.getId(), token);
    
    return Result.ok(Collections.singletonMap("token", token));
}

想温柔的对待这个世界