2025.08.16

黑马点评开发日志Day01——项目介绍、环境搭建及短信登录功能

一、项目介绍

1.1 项目功能

  • 短信登录
    • 使用redis共享session来实现
  • 商户查询缓存
    • 包含缓存击穿,缓存穿透,缓存雪崩等内容
  • 优惠卷秒杀
    • 使用Redis的计数器功能,结合Lua完成高性能的redis操作,同时学习Redis分布式锁的原理,包括Redis的三种消息队列
  • 附近的商户
    • 利用Redis的GEOHash来完成对于地理坐标的操作
  • UV统计
    • 使用Redis来完成统计功能
  • 用户签到
    • 使用Redis的BitMap数据统计功能
  • 好友关注
    • 基于Set集合的关注、取消关注,共同关注等等功能
  • 达人探店
    • 基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能

项目功能

二、环境搭建

2.1 导入SQL

  • 新建数据库hmdp,导入hmdp.sql文件。

  • 步骤:新建数据库hmdp->右键sql脚本->选择hmdp.sql文件->点击执行

导入SQL

SQL表

  • 其中比较核心的表:
    • tb_user:用户表
    • tb_user_info:用户详情表
    • tb_shop:商户信息表
    • tb_shop_type:商户类型表
    • tb_blog:用户日记表(达人探店日记)
    • tb_follow:用户关注表
    • tb_voucher:优惠券表
    • tb_voucher_order:优惠券的订单表

2.2 导入项目

  • 手机或者app端发起请求,请求nginx服务器,nginx基于七层模型的HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量。

  • 在tomcat支撑起并发流量后,如果让tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32核心cpu,32或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以在高并发场景下会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。

项目结构

导入项目

  • 修改application.yaml文件中的数据库和redis连接信息:
    修改配置

2.3 启动项目

  • 启动HmDianPingApplication类进行测试发现报错:
    启动项目

  • 原因:springboot版本过低,不支持Java17

  • 解决方法:修改pom.xml文件中的springboot版本为2.7.3

  • 再次启动,启动成功:
    启动成功

  • 访问http://localhost:8081/shop-type/list进行测试
    测试
    测试成功

2.4 测试前端代码

  • 启动nginx.exe,访问http://localhost:8080/,并打开手机模式,就可以看到前端页面
    前端测试

三、基于Session实现短信登录功能

3.1 基于Session实现登录

  • 登录步骤:
    • 发送短信验证码
    • 短信验证码登录、注册
    • 校验登录状态

短信登录

3.2 发送短信验证码——需求分析

  • 产品原型
    发送短信验证码

3.3 发送短信验证码——代码实现

3.3.1 UserController

1
2
3
4
5
6
7
8
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}

3.3.2 IUserService

1
2
3
4
5
6
7
/**
* 发送手机验证码
* @param phone
* @param session
* @return
*/
Result sendCode(String phone, HttpSession session);

3.3.3 UserServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 发送手机验证码
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 校验手机号
if(RegexUtils.isPhoneInvalid(phone)) {
// 如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}

// 符合,生成验证码
String code = RandomUtil.randomNumbers(6);

// 保存验证码到session
session.setAttribute("code", code);

// 发送验证码
log.debug("短信验证码发送成功,验证码为:{}", code);

return Result.ok();
}

3.4 发送短信验证码——功能测试

  • 发送短信验证码
    发送短信验证码

3.5 短信验证码登录——需求分析

  • 产品原型
    短信验证码登录

3.6 短信验证码登录——代码实现

3.6.1 UserController

1
2
3
4
5
6
7
8
9
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm, session);
}

3.6.2 IUserService

1
2
3
4
5
6
7
/**
* 登录功能
* @param loginForm
* @param session
* @return
*/
Result login(LoginFormDTO loginForm, HttpSession session);

3.6.3 UserServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 登录功能
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)) {
// 如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}

// 校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}

// 一致,根据手机号查询用户
// mybatisplus
User user = query().eq("phone", phone).one();

// 判断用户是否存在
if (user == null) {
// 不存在,创建新用户并保存
user = creatUserWithPhone(phone);
}

// 存在,保存用户信息到session
session.setAttribute("user", user);

return Result.ok();
}

private User creatUserWithPhone(String phone) {
// 创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
// 保存用户
save(user);
return user;
}

3.7 短信验证码登录——功能测试

  • 短信验证码登录
    短信验证码登录

3.8 校验登录状态——需求分析

  • 产品原型
    校验登录状态
  • 问题:当session请求变多时,每一个session都要单独进行校验,浪费资源
    • 解决方案:使用拦截器进行校验
  • 为了保护用户隐私,使用UserDTO代替User作为返回结果,以隐藏用户关键信息

3.9 校验登录状态——代码实现

3.9.1 LoginInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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. 不存在,拦截
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();
}
}

3.9.2 MvcConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class MvcConfig implements WebMvcConfigurer {

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

3.9.3 UserController

1
2
3
4
5
6
@GetMapping("/me")
public Result me(){
// 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}

四、基于Redis实现共享session登录

4.1 集群的session共享问题

  • session共享问题:多台tomcat并不共享session存储空间,当请求切换到不同tomcat服务器时会导致数据丢失
  • session的替代方案应满足:
    • 数据共享
    • 内存存储
    • key-value结构
  • 解决方案:使用Redis来存储session数据

4.2 基于Redis实现共享session登录——需求分析

  • 变化:
    • 使用session时由于每个请求都有独立的session空间,所以在保存验证码时key使用code字段即可,但在使用Redis存储session时,key需要包含用户的唯一标识(如手机号)以区分不同用户的session
    • 在保存用户信息时key使用token字段,value为用户信息

4.3 基于Redis实现共享session登录——代码实现

4.3.1 UserServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/**
* 发送手机验证码
* @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);

// // 保存验证码到session
// session.setAttribute("code", code);

// 4. 保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

// 5. 发送验证码
log.debug("短信验证码发送成功,验证码为:{}", code);

return Result.ok();
}

/**
* 登录功能
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)) {
// 2. 如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}

// 3. 校验验证码
// String cacheCode = session.getAttribute("code");
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
// 4. 不一致,报错
return Result.fail("验证码错误");
}

// 5. 一致,根据手机号查询用户
// mybatisplus
User user = query().eq("phone", phone).one();

// 6. 判断用户是否存在
if (user == null) {
// 不存在,创建新用户并保存
user = creatUserWithPhone(phone);
}

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

// 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);
// 7.3 存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4 设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

// 8. 返回token
return Result.ok(token);
}

4.3.2 LoginInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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. 获取session
// HttpSession session = request.getSession();
// // 2. 获取session中的用户
// Object user = session.getAttribute("user");
// // 3. 判断用户是否存在
// if (user == null) {
// // 4. 不存在,拦截
// response.setStatus(401);
// return false;
// }
// 1. 获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
// 不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
// 2. 基于token获取redis中的用户
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
// 3. 判断用户是否存在
if (userMap.isEmpty()) {
// 4. 不存在,拦截
response.setStatus(401);
return false;
}
// 5. 将查询到的hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6. 存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
// 7. 刷新token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8. 放行
return true;
}


@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}

4.3.3 MvcConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Resource
private StringRedisTemplate stringRedisTemplate;

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

4.4 基于Redis实现共享session登录——功能测试

  • 登录时发生错误:服务器异常
    登录时发生错误
    登录时发生错误
  • 原因:我们使用的StringRedisTemplate类要求的key和value都是String类型,而UserDTO中的Id为Long类型,所以在类型转化时出现错误
  • 解决方法:在UserServiceImpl中将UserDTO转为Map时使用BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create().setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())),将所有非String类型的值转为String类型
  • UserServiceImpl
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
      /**
    * 登录功能
    * @param loginForm
    * @param session
    * @return
    */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1. 校验手机号
    String phone = loginForm.getPhone();
    if(RegexUtils.isPhoneInvalid(phone)) {
    // 2. 如果不符合,返回错误信息
    return Result.fail("手机号格式错误");
    }
    // 3. 校验验证码
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.toString().equals(code)) {
    // 4. 不一致,报错
    return Result.fail("验证码错误");
    }
    // 5. 一致,根据手机号查询用户
    // mybatisplus
    User user = query().eq("phone", phone).one();
    // 6. 判断用户是否存在
    if (user == null) {
    // 不存在,创建新用户并保存
    user = creatUserWithPhone(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().
    setIgnoreNullValue(true).
    setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 7.3 存储
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 7.4 设置token有效期
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 8. 返回token
    return Result.ok(token);
    }
  • 再次登录,登录成功
    登录成功
  • 查看redis中存储的验证码和用户信息
    查看redis中存储的验证码和用户信息

4.5 拦截器优化

  • 目前存在的问题:只有访问拦截到的拦截器中需要拦截的接口时才会刷新token的有效期,如果访问其他接口则不会刷新token的有效期,导致用户登录失效
  • 解决方案:再添加一个拦截器,该拦截器配置所有路径,但不进行拦截,只进行token有效期的刷新

4.5.1 RefreshTokenInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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 tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
// 3. 判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5. 将查询到的hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6. 存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
// 7. 刷新token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8. 放行
return true;
}


@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}

4.5.2 LoginInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class LoginInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 判断是否需要拦截(ThreadLocal中是否有用户)
if (UserHolder.getUser() == null) {
// 没有,需要拦截,设置状态码
response.setStatus(401);
// 拦截
return false;
}
// 有用户则放行
return true;
}


@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}

4.5.3 MvcConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/login",
"/user/code",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
).order(1);

// token刷新拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**").order(0);
}
}