项目环境搭建
数据库初始化
在项目启动前,需要先完成数据库的准备工作:
-- 导入项目所需的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进行部署:
- 解压Nginx安装包至目标目录
- 修改conf目录下的端口配置为8971
- 启动Nginx服务
- 访问
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.UserServiceImpl 的 sendCode 方法
实现逻辑:将验证码存储从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.UserServiceImpl 的 login 方法
实现逻辑:生成唯一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.UserServiceImpl 的 login 方法新增日志记录
实现逻辑:在登录成功和失败的关键节点记录日志,便于问题排查和用户行为分析
@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));
}