导入
数据库
- 表结构:
- 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实现登录

发送短信验证码
- 请求规范:
- 方式: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接口的拦截器,用于验证用户是否已登录:
-
preHandle方法(请求处理前执行):
- 从session中获取用户信息
- 如果用户未登录(session中没有user),返回401状态码并拦截请求
- 如果用户已登录,将用户信息保存到UserHolder中,供后续操作使用
-
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存储当前线程的用户信息,实现用户信息在线程内的共享和隔离:
- saveUser():保存用户信息到当前线程
- getUser():获取当前线程的用户信息
- removeUser():清除当前线程的用户信息
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();
}
}整体流程
- 用户访问需要登录的接口时,LoginInterceptor会拦截请求
- 拦截器检查session中是否存在用户信息
- 如果用户已登录,将用户信息存入UserHolder
- 控制器中可以通过UserHolder.getUser()获取当前登录用户信息
- 请求完成后清理UserHolder中的用户信息
这种设计实现了用户登录状态的统一管理,避免了在每个控制器方法中重复检查登录状态的代码。
session共享

Redis代替session的业务流程


验证码key的唯一性
- session特性:每个浏览器有独立session,不同请求互不干扰
- Redis特性:共享内存空间,所有请求共用同一个Redis实例
- key设计:
- 问题:使用固定key会导致不同手机号的验证码互相覆盖
- 解决方案:以手机号为key存储验证码
- 优势:确保唯一性且便于后续验证时获取
用户信息数据类型的选择
- 可选方案:
- String结构:将对象序列化为JSON字符串存储
- 优点:直观易读
- 缺点:字段耦合,修改需整体替换;内存占用较大
- Hash结构:将对象字段拆分为field-value对存储
- 优点:支持字段级CRUD;内存占用更少
- 缺点:结构相对复杂
- String结构:将对象序列化为JSON字符串存储
- 推荐方案:优先选择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();
}
}工作流程
- 用户请求验证码 → 验证码存储在Redis中,设置2分钟有效期
- 用户登录 → 验证Redis中的验证码 → 验证通过后生成token,用户信息存储到Redis,返回token给前端
- 用户后续请求 → 请求头携带token → 拦截器从Redis获取用户信息 → 刷新token有效期
这次改动实现了现代化的无状态认证机制,为系统扩展性和安全性提供了更好的支持。
拦截器的优化

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