user模块
0° 接口
| 登录 | ||
| 注册 | ||
| 获取用户信息 | ||
| 用户退出登录 | ||
| 修改用户普通信息 | ||
| 修改用户名 | ||
| 修改密码 | ||
| 绑定/修改手机号 | ||
| 绑定/修改邮箱 | ||
| 用户注销 | ||
| 图形验证码 |
1° 统一响应类 Result
package com.mall.common.result;
import com.mall.common.enums.ResultCode;
import lombok.Data;
import java.io.Serializable;
@Data
publicclassResult<T> implementsSerializable {
private Integer code;
private String message;
private T data;
publicstatic <T> Result<T> ok() {
Result<T> result = newResult<>();
result.setCode(ResultCode.SUCCESS.getCode());
result.setMessage(ResultCode.SUCCESS.getMessage());
return result;
}
publicstatic <T> Result<T> ok(T data) {
Result<T> result = ok();
result.setData(data);
return result;
}
publicstatic <T> Result<T> ok(String message, T data) {
Result<T> result = ok(data);
result.setMessage(message);
return result;
}
publicstatic <T> Result<T> fail() {
Result<T> result = newResult<>();
result.setCode(ResultCode.INTERNAL_SERVER_ERROR.getCode());
result.setMessage(ResultCode.INTERNAL_SERVER_ERROR.getMessage());
return result;
}
publicstatic <T> Result<T> fail(String message) {
Result<T> result = newResult<>();
result.setCode(ResultCode.INTERNAL_SERVER_ERROR.getCode());
result.setMessage(message);
return result;
}
publicstatic <T> Result<T> fail(ResultCode resultCode) {
Result<T> result = newResult<>();
result.setCode(resultCode.getCode());
result.setMessage(resultCode.getMessage());
return result;
}
publicstatic <T> Result<T> fail(Integer code, String message) {
Result<T> result = newResult<>();
result.setCode(code);
result.setMessage(message);
return result;
}
publicstatic <T> Result<T> error(String message) {
return fail(message);
}
publicstatic <T> Result<T> error(ResultCode resultCode) {
return fail(resultCode);
}
publicstatic <T> Result<T> error(Integer code, String message) {
return fail(code, message);
}
}
1′ 为什么要实现序列化
① Redis 缓存序列化(最主要原因)
如果把Result对象、或者里面的 data实体丢进 Redis,Redis 存储的是字节数组。
需要 JDK 序列化把 Java 对象转为 byte [] 存入 Redis;读取时反序列化还原对象。
不实现 Serializable,直接存对象到 Redis 直接抛异常 NotSerializableException
不仅仅 Result,存 Redis 的实体(User 等)同样建议实现Serializable。
② RPC 远程调用(微服务)
Dubbo / Feign 远程调用,服务之间传输 Result 对象,网络传输也要序列化为字节流。
对象需要可序列化,跨服务网络传输。
③ 会话序列化 HttpSession 钝化
Tomcat 会把 Session 对象序列化写入磁盘,重启服务恢复 session。如果 Session 存放 Result 对象,要求实现序列化。
2′ ResultCode 枚举(避免魔法值)
package com.mall.common.enums;
import lombok.Getter;
/**
* 统一返回结果集
*/
@Getter
publicenumResultCode {
SUCCESS(200, "success"),
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未登录或token已过期"),
FORBIDDEN(403, "没有相关权限"),
NOT_FOUND(404, "资源不存在"),
INTERNAL_SERVER_ERROR(500, "服务器内部错误"),
// 用户模块业务错误
USER_USERNAME_EXISTS(1001, "用户名已存在"),
USER_PHONE_EXISTS(1002, "手机号已被注册"),
USER_EMAIL_EXISTS(1003, "邮箱已被注册"),
USER_LOGIN_ERROR(1004, "用户名或密码错误"),
USER_DISABLED(1005, "账号已被禁用"),
USER_NOT_FOUND(1006, "用户不存在");
privatefinal Integer code;
privatefinal String message;
ResultCode(Integer code, String message) {
this.code = code;
this.message = message;
}
}
命名推荐:不加Enum后缀
3′ ok(), fail() , error()
正常完成业务 ok()
用户操作不合规则、业务校验不通过 fail()
程序意外报错、服务器内部故障 error()
2° 完整的异常处理链路
1. Controller 接收请求
↓2. 调用 Service 业务逻辑
↓3. Service 发现异常
↓4. throw new BusinessException(...)
↓5. GlobalExceptionHandler 拦截
↓6. 根据异常类型匹配对应的处理方法
↓7. 包装成 Result 返回 JSON
1′ 创建自定义业务异常
package com.mall.common.exception;
import com.mall.common.enums.ResultCode;
import lombok.Getter;
/**
* 业务异常
* 用于业务逻辑中抛出可预期的异常,由全局异常处理器统一处理
*/
@Getter
publicclassBusinessExceptionextendsRuntimeException {
privatefinal Integer code;
publicBusinessException(String message) {
super(message);
this.code = ResultCode.INTERNAL_SERVER_ERROR.getCode();
}
publicBusinessException(ResultCode resultCode) {
super(resultCode.getMessage());
this.code = resultCode.getCode();
}
publicBusinessException(Integer code, String message) {
super(message);
this.code = code;
}
}
2′ 创建全局异常处理器
package com.mall.common.handler;
import com.mall.common.enums.ResultCode;
import com.mall.common.exception.BusinessException;
import com.mall.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理器
* 统一处理 Controller 层抛出的异常,返回标准格式的响应
*/
@Slf4j
@RestControllerAdvice
publicclassGlobalExceptionHandler {
/**
* 处理业务异常
*/
@ExceptionHandler
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("业务异常:{}", e.getMessage());
return Result.fail(e.getCode(), e.getMessage());
}
/**
* 处理参数校验异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
Stringmessage= e.getBindingResult().getFieldError() != null
? e.getBindingResult().getFieldError().getDefaultMessage()
: "参数校验失败";
log.warn("参数校验异常:{}", message);
return Result.fail(ResultCode.BAD_REQUEST.getCode(), message);
}
/**
* 处理参数绑定异常
*/
@ExceptionHandler(BindException.class)
public Result<Void> handleBindException(BindException e) {
Stringmessage= e.getBindingResult().getFieldError() != null
? e.getBindingResult().getFieldError().getDefaultMessage()
: "参数绑定失败";
log.warn("参数绑定异常:{}", message);
return Result.fail(ResultCode.BAD_REQUEST.getCode(), message);
}
/**
* 处理其他未知异常
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常:", e);
return Result.fail(ResultCode.INTERNAL_SERVER_ERROR);
}
}
为啥是public Result
因为返回体里没有数据要返回,只返回状态码和消息就够了
3° 用户注册、登录
1′ JWT 配置类
package com.mall.common.config;
import com.mall.common.constant.HttpHeaderConstants;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* JWT 配置属性
* 从配置文件中读取 JWT 相关配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
publicclassJwtProperties {
/**
* 密钥(用于签名和验证 Token)
*/
privateStringsecret="mall-secret-key-for-jwt-token-generation-2024-secure4";
/**
* 过期时间(单位:毫秒),默认 7 天
*/
privateLongexpiration=604800000L;
/**
* 请求头名称
*/
privateStringheader= HttpHeaderConstants.AUTHORIZATION;
}
JwtProperties 就是一个"配置搬运工":把 application.yml 中的 JWT 配置自动搬到 Java 类中,让其他地方可以通过依赖注入使用,实现配置和代码分离。赋初值的原因:双重保险(优先级:配置文件的值 > 代码中的默认值)
application-dev.yml里新增配置:
jwt:
secret: mall-secret-key-for-jwt-token-generation-2024-secure
expiration: 604800000 # 7天,单位毫秒
header: Authorization
2′ JWT 工具类
package com.jx.mall.common.utils;
import com.jx.mall.common.config.JwtProperties;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT 工具类
* 提供 Token 的生成、解析、验证功能
*/
@Slf4j
@Component
@RequiredArgsConstructor
publicclassJwtUtil {
privatefinal JwtProperties jwtProperties;
/**
* 获取签名密钥
*/
private SecretKey getSecretKey() {
return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
}
/**
* 生成 Token
*
* @param userId 用户ID
* @param username 用户名
* @return JWT Token
*/
public String generateToken(Long userId, String username) {
return Jwts.builder()
.subject(String.valueOf(userId))
.claim("username", username)
.issuedAt(newDate())
.expiration(newDate(System.currentTimeMillis() + jwtProperties.getExpiration()))
.signWith(getSecretKey())
.compact();
}
/**
* 解析 Token
*
* @param token JWT Token
* @return Claims(包含用户信息的载荷)
*/
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getSecretKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 从 Token 中获取用户ID
*/
public Long getUserId(String token) {
Claimsclaims= parseToken(token);
return Long.valueOf(claims.getSubject());
}
/**
* 从 Token 中获取用户名
*/
public String getUsername(String token) {
Claimsclaims= parseToken(token);
return claims.get("username", String.class);
}
/**
* 验证 Token 是否有效
*/
publicbooleanvalidateToken(String token) {
try {
parseToken(token);
returntrue;
} catch (ExpiredJwtException e) {
log.warn("Token 已过期: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.warn("不支持的 Token: {}", e.getMessage());
} catch (MalformedJwtException e) {
log.warn("Token 格式错误: {}", e.getMessage());
} catch (SignatureException e) {
log.warn("Token 签名无效: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.warn("Token 为空: {}", e.getMessage());
}
returnfalse;
}
}
构造器注入,不用 @Autowired
@RequiredArgsConstructor
private final JwtProperties jwtProperties;
@Autowired缺点:
1. 一个接口有多个实现类,字段注入直接报错 2. @Autowired是Spring自家定义的注解,后续如果更换容器,代码需要大范围修改,比JSR-250的@Resource、JSR-303的@Inject通用性差 3. 字段注入无法声明为final,对象可以被二次修改,安全性弱 4. 字段注入,写单元测试时,如果不启动Spring容器,自己new这个类,依赖会是null,必须额外做反射赋值,测试麻烦。
3′ 创建密码加密工具类
package com.mall.common.utils;
import cn.hutool.crypto.digest.BCrypt;
/**
* 密码工具类
* 使用 BCrypt 算法进行密码加密和验证
* BCrypt 是一种安全的单向哈希算法,自带盐值,防止彩虹表攻击
*/
publicclassPasswordUtil {
privatePasswordUtil() {
}
/**
* 加密密码
*
* @param rawPassword 原始密码
* @return 加密后的密码
*/
publicstatic String encode(String rawPassword) {
return BCrypt.hashpw(rawPassword);
}
/**
* 验证密码
*
* @param rawPassword 原始密码
* @param encodedPassword 加密后的密码
* @return 是否匹配
*/
publicstaticbooleanmatches(String rawPassword, String encodedPassword) {
return BCrypt.checkpw(rawPassword, encodedPassword);
}
}
4′ 创建用户上下文工具类
package com.mall.common.utils;
import com.mall.common.constant.HttpHeaderConstants;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 用户上下文工具类
* 从请求中获取当前登录用户信息
* 需要配合 AuthenticationInterceptor 使用
*/
publicclassUserContext {
privateUserContext() {
}
/**
* 获取当前用户ID
*
* @return 用户ID,未登录返回 null
*/
publicstatic Long getCurrentUserId() {
HttpServletRequestrequest= getRequest();
if (request != null) {
ObjectuserId= request.getAttribute("userId");
return userId != null ? (Long) userId : null;
}
returnnull;
}
/**
* 获取当前用户名
*
* @return 用户名,未登录返回 null
*/
publicstatic String getCurrentUsername() {
HttpServletRequestrequest= getRequest();
if (request != null) {
Objectusername= request.getAttribute("username");
return username != null ? (String) username : null;
}
returnnull;
}
/**
* 获取当前请求对象
*/
privatestatic HttpServletRequest getRequest() {
ServletRequestAttributesattributes= (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes != null ? attributes.getRequest() : null;
}
/**
* 获取当前请求的 Token
* @return Token,未登录返回 null
*/
publicstatic String getToken() {
HttpServletRequestrequest= getRequest();
if (request != null) {
return request.getHeader(HttpHeaderConstants.AUTHORIZATION);
}
returnnull;
}
}
搭配下面的拦截器使用
5′ 创建认证拦截器
package com.mall.common.interceptor;
import com.alibaba.fastjson.JSON;
import com.mall.common.config.JwtProperties;
import com.mall.common.enums.ResultCode;
import com.mall.common.result.Result;
import com.mall.common.service.TokenBlacklistService;
import com.mall.common.utils.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 认证拦截器
* 从请求头中解析 Token,提取用户信息存入请求属性
*/
@Component
@RequiredArgsConstructor
publicclassAuthenticationInterceptorimplementsHandlerInterceptor {
privatefinal JwtUtil jwtUtil;
privatefinal JwtProperties jwtProperties;
privatefinal TokenBlacklistService tokenBlacklistService;
@Override
publicbooleanpreHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception {
Stringtoken= request.getHeader(jwtProperties.getHeader());
// token 缺失或格式不对
if (token == null || !token.startsWith("Bearer ")) {
sendUnauthorizedResponse(response);
returnfalse;
}
token = token.substring(7);
// token 无效或已过期
if (!jwtUtil.validateToken(token)) {
sendUnauthorizedResponse(response);
returnfalse;
}
// 检查 Token 是否已被加入黑名单(用户已退出登录)
if (tokenBlacklistService.isBlacklisted(token)) {
sendUnauthorizedResponse(response);
returnfalse;
}
LonguserId= jwtUtil.getUserId(token);
Stringusername= jwtUtil.getUsername(token);
request.setAttribute("userId", userId);
request.setAttribute("username", username);
returntrue;
}
/**
* 返回 401 未授权响应
*/
privatevoidsendUnauthorizedResponse(HttpServletResponse response)throws Exception {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
Result<Void> result = Result.fail(ResultCode.UNAUTHORIZED);
response.getWriter().write(JSON.toJSONString(result));
}
}6′ 配置拦截器
package com.jx.mall.common.config;
import com.jx.mall.common.interceptor.AuthenticationInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置
* 配置拦截器、跨域等
*/
@Configuration
@RequiredArgsConstructor
publicclassWebMvcConfigimplementsWebMvcConfigurer {
privatefinal AuthenticationInterceptor authenticationInterceptor;
@Override
publicvoidaddInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authenticationInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/user/register",
"/user/login",
"/doc.html",
"/swagger-resources/**",
"/v3/api-docs/**",
"/webjars/**"
);
}
}
7′ 创建常量类
集中管理业务字段的校验规则
package com.mall.common.constant;
/**
* 集中管理业务字段的校验规则
*/
publicclassFieldConstants {
privateFieldConstants() {
}
// 用户名
publicstaticfinalintUSERNAME_MIN=3;
publicstaticfinalintUSERNAME_MAX=50;
// 密码
publicstaticfinalintPASSWORD_MIN=6;
publicstaticfinalintPASSWORD_MAX=20;
// 手机号
publicstaticfinalintPHONE_MAX=20;
// 邮箱
publicstaticfinalintEMAIL_MAX=100;
}
8′ 创建 DTO 类
1′′ req
1′′′ UserRegisterDTO
package com.mall.module.user.pojo.dto.req;
import com.mall.common.constant.FieldConstants;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 用户注册请求参数
*/
@Data
publicclassUserRegisterDTO {
@NotBlank(message = "用户名不能为空")
@Size(min = FieldConstants.USERNAME_MIN,
max = FieldConstants.USERNAME_MAX,
message = "用户名长度必须在3-50个字符之间")
@Pattern(
regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9_]+$",
message = "用户名只能包含常用中文、字母、数字和下划线"
)
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = FieldConstants.PASSWORD_MIN,
max = FieldConstants.PASSWORD_MAX,
message = "密码长度必须在6到20个字符之间"
)
private String password;
@Pattern(regexp = "^$|^1[3-9]\\d{9}$",
message = "手机号格式不正确"
)
@Size(max = FieldConstants.PHONE_MAX,
message = "手机号长度最多20位"
)
private String phone;
@Pattern(regexp = "^$|^[\\w.-]+@[\\w.-]+\\.\\w+$",
message = "邮箱格式不正确"
)
@Size(max = FieldConstants.EMAIL_MAX,
message = "邮箱长度最多100位"
)
private String email;
}
2′′′ UserLoginDTO
package com.mall.module.user.pojo.dto.req;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 用户登录请求参数
*/
@Data
publicclassUserLoginDTO {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
2′′ resp
1′′′ UserInfoResponse
package com.mall.module.user.pojo.dto.resp;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 用户信息响应对象
* 用于返回用户信息给前端,不包含密码等敏感字段
*/
@Data
publicclassUserInfoResponse {
private Long id;
private String username;
private String realName;
private String phone;
private String email;
private String avatar;
private Integer gender;
private LocalDate birthday;
private Integer status;
private LocalDateTime createTime;
}
2′′′ LoginResponse
package com.mall.module.user.pojo.dto.resp;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 登录响应对象
* 用户登录成功后返回,包含 JWT Token 和用户信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
publicclassLoginResponse {
private String token;
private UserInfoResponse user; // 包含用户信息
}
3′′′ resp 和 vo 的区别
RespDTO
应用层向外输出,原始业务数据载体,包含完整业务字段,可能被多个接口复用。
VO
在 RespDTO 基础上按需裁剪、重组、适配前端页面。
• 剔除不需要字段、字段改名、格式化日期、拼接展示字段
流程:Entity (数据库) → DTO → VO → 返回前端
9′ 完善 UserMapper
package com.mall.module.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mall.module.user.pojo.entity.User;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户 Mapper 接口
* 继承 MyBatis-Plus 的 BaseMapper,自动拥有 CRUD 方法
*/
@Mapper
publicinterfaceUserMapperextendsBaseMapper<User> {
}
10′ 创建 UserService接口
package com.mall.module.user.service;
import com.mall.module.user.pojo.dto.req.UserLoginDTO;
import com.mall.module.user.pojo.dto.req.UserRegisterDTO;
import com.mall.module.user.pojo.dto.resp.LoginResponse;
import com.mall.module.user.pojo.dto.resp.UserInfoResponse;
/**
* 用户服务接口
*/
publicinterfaceUserService {
/**
* 用户注册
* @param dto 注册参数
*/
voidregister(UserRegisterDTO dto);
/**
* 用户登录
* @param dto 登录参数
* @return 登录结果 (token + 用户信息)
*/
LoginResponse login(UserLoginDTO dto);
/**
* 获取当前用户信息
* @param userId 用户id
* @return 用户信息
*/
UserInfoResponse getCurrentUser(Long userId);
}
11′ 创建 UserServiceImpl
package com.mall.module.user.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mall.common.enums.ResultCode;
import com.mall.common.enums.UserStatus;
import com.mall.common.exception.BusinessException;
import com.mall.common.utils.JwtUtil;
import com.mall.common.utils.PasswordUtil;
import com.mall.module.user.mapper.UserMapper;
import com.mall.module.user.pojo.dto.req.UserLoginDTO;
import com.mall.module.user.pojo.dto.req.UserRegisterDTO;
import com.mall.module.user.pojo.dto.resp.LoginResponse;
import com.mall.module.user.pojo.dto.resp.UserInfoResponse;
import com.mall.module.user.pojo.entity.User;
import com.mall.module.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
publicclassUserServiceImplimplementsUserService {
privatefinal UserMapper userMapper;
privatefinal JwtUtil jwtUtil;
/**
* 用户注册
* 流程:
* 1. 检查用户名是否已存在
* 2. 检查手机号是否已存在(如果填写了)
* 3. 检查邮箱是否已存在(如果填写了)
* 4. 对密码进行 BCrypt 加密
* 5. 保存用户信息到数据库
*/
@Override
@Transactional(rollbackFor = Exception.class)
publicvoidregister(UserRegisterDTO dto) {
LambdaQueryWrapper<User> wrapper = newLambdaQueryWrapper<>();
wrapper.eq(User::getUsername, dto.getUsername());
if (userMapper.selectCount(wrapper) > 0) {
thrownewBusinessException(ResultCode.USER_USERNAME_EXISTS);
}
Stringphone= dto.getPhone();
if (phone != null && !phone.isEmpty()) {
wrapper = newLambdaQueryWrapper<>();
wrapper.eq(User::getPhone, phone);
if (userMapper.selectCount(wrapper) > 0) {
thrownewBusinessException(ResultCode.USER_PHONE_EXISTS);
}
}
Stringemail= dto.getEmail();
if (email != null && !email.isEmpty()) {
wrapper = newLambdaQueryWrapper<>();
wrapper.eq(User::getEmail, email);
if (userMapper.selectCount(wrapper) > 0) {
thrownewBusinessException(ResultCode.USER_EMAIL_EXISTS);
}
}
Useruser=newUser();
user.setUsername(dto.getUsername());
user.setPassword(PasswordUtil.encode(dto.getPassword()));
user.setPhone(phone);
user.setEmail(email);
user.setStatus(UserStatus.NORMAL.getCode());
userMapper.insert(user);
}
/**
* 用户登录
* 流程:
* 1. 根据用户名查询用户
* 2. 验证密码是否正确
* 3. 检查账号状态是否正常
* 4. 生成 JWT Token
* 5. 返回 Token 和用户信息
*/
@Override
public LoginResponse login(UserLoginDTO dto) {
LambdaQueryWrapper<User> wrapper = newLambdaQueryWrapper<>();
wrapper.eq(User::getUsername, dto.getUsername());
Useruser= userMapper.selectOne(wrapper);
if (user == null) {
thrownewBusinessException(ResultCode.USER_LOGIN_ERROR);
}
if (!PasswordUtil.matches(dto.getPassword(), user.getPassword())) {
thrownewBusinessException(ResultCode.USER_LOGIN_ERROR);
}
if (user.getStatus() == UserStatus.DISABLED.getCode()) {
thrownewBusinessException(ResultCode.USER_DISABLED);
}
Stringtoken= jwtUtil.generateToken(user.getId(), user.getUsername());
UserInfoResponseuserInfoResponse= BeanUtil.copyProperties(user, UserInfoResponse.class);
returnnewLoginResponse(token, userInfoResponse);
}
@Override
public UserInfoResponse getCurrentUser(Long userId) {
Useruser= userMapper.selectById(userId);
if (user == null) {
thrownewBusinessException(ResultCode.USER_NOT_FOUND);
}
return BeanUtil.copyProperties(user, UserInfoResponse.class);
}
}
12′ 创建 UserController
package com.mall.module.user.controller;
import com.mall.common.enums.ResultCode;
import com.mall.common.result.Result;
import com.mall.common.utils.UserContext;
import com.mall.module.user.pojo.dto.req.UserLoginDTO;
import com.mall.module.user.pojo.dto.req.UserRegisterDTO;
import com.mall.module.user.pojo.dto.resp.LoginResponse;
import com.mall.module.user.pojo.dto.resp.UserInfoResponse;
import com.mall.module.user.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 用户控制器
* 提供用户注册、登录、获取用户信息等接口
*/
@Tag(name = "用户管理")
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
publicclassUserController {
privatefinal UserService userService;
/**
* 用户注册
*/
@Operation(summary = "用户注册")
@PostMapping("/register")
public Result<Void> register(@Valid@RequestBody UserRegisterDTO dto) {
userService.register(dto);
return Result.ok("注册成功", null);
}
/**
* 用户登录
*/
@Operation(summary = "用户登录")
@PostMapping("/login")
public Result<LoginResponse> login(@Valid@RequestBody UserLoginDTO dto) {
LoginResponseresponse= userService.login(dto);
return Result.ok(response);
}
/**
* 获取当前用户信息
*/
@Operation(summary = "获取当前用户信息")
@GetMapping("/info")
public Result<UserInfoResponse> getCurrentUser() {
LongcurrentUserId= UserContext.getCurrentUserId();
if (currentUserId == null) {
return Result.fail(ResultCode.UNAUTHORIZED);
}
UserInfoResponsecurrentUser= userService.getCurrentUser(currentUserId);
return Result.ok(currentUser);
}
}
13′ 更新 MyMetaObjectHandler
package com.mall.common.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.mall.common.utils.UserContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Slf4j
@Component
publicclassMyMetaObjectHandlerimplementsMetaObjectHandler {
@Override
publicvoidinsertFill(MetaObject metaObject) {
log.debug("自动填充:插入操作");
// 创建时间
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
// 更新时间
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
// 创建人(从当前线程获取用户)
this.strictInsertFill(metaObject, "createBy", String.class, getCurrentUsername());
// 更新人
this.strictInsertFill(metaObject, "updateBy", String.class, getCurrentUsername());
// 逻辑删除默认值
this.strictInsertFill(metaObject, "isDeleted", Integer.class, 0);
}
@Override
publicvoidupdateFill(MetaObject metaObject) {
log.debug("自动填充:更新操作");
// 更新时间
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
// 更新人
this.strictUpdateFill(metaObject, "updateBy", String.class, getCurrentUsername());
}
/**
* 获取当前操作用户
* 优先从 UserContext 获取已登录用户,否则返回 system
*/
private String getCurrentUsername() {
Stringusername= UserContext.getCurrentUsername();
return username != null ? username : "system";
}
}14′ 接口文档查看
http://localhost:8080/api/swagger-ui/index.html
新增配置类OpenApiConfig
package com.mall.common.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
publicclassOpenApiConfig {
@Bean
public OpenAPI openAPI() {
returnnewOpenAPI()
.info(newInfo()
.title("商城接口文档")
.description("用户注册、登录等接口")
.version("v1.0.0")
.contact(newContact()
.name("mall")));
}
}修改WebMvcConfig
package com.mall.common.config;
import com.mall.common.interceptor.AuthenticationInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置
* 配置拦截器、跨域等
*/
@Configuration
@RequiredArgsConstructor
publicclassWebMvcConfigimplementsWebMvcConfigurer {
privatefinal AuthenticationInterceptor authenticationInterceptor;
@Override
publicvoidaddInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authenticationInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/user/register",
"/user/login",
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/v3/api-docs",
"/webjars/**",
"/favicon.ico"
);
}
}
4° 用户退出登录
实现思路:
JWT Token 本身是无状态的,服务端不保存任何 Session。用户退出登录时,服务端无法主动让 Token 失效。
解决办法:把退出登录的 Token 记在 Redis 里,形成一个"黑名单"。后续任何请求进来,先检查 Token 是否在黑名单中,如果在就直接拦截。
1′ 在 application-dev.yml 配置文件中新增redis配置信息
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: # Redis 没有密码就留空
database: 0
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 02′ 测试 redis 链接
package com.mall;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@SpringBootTest
publicclassRedisTest {
privatefinal StringRedisTemplate stringRedisTemplate;
// 构造器注入
@Autowired
publicRedisTest(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Test
publicvoidtestRedisConnection() {
stringRedisTemplate.opsForValue().set("test_key", "hello redis!");
StringtestKeyValue= stringRedisTemplate.opsForValue().get("test_key");
System.out.println("value:" + testKeyValue);
}
}
3′ 创建 TokenBlacklistService 类
当用户退出登录时,把TA的 JWT Token 放进 Redis 黑名单里。这样即使这个 Token 还没过期,后续拿着它来访问接口也会被拦截,相当于让 Token 提前失效。
package com.mall.common.service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* Token 黑名单服务
* 用于实现用户退出登录功能
*/
@Service
@RequiredArgsConstructor
publicclassTokenBlacklistService {
privatestaticfinalStringBLACKLIST_PREFIX="token:blacklist:";
privatefinal StringRedisTemplate stringRedisTemplate;
/**
* 将 Token 加入黑名单
* @param token JWT Token
* @param expireSeconds 过期时间(秒),应与 Token 剩余有效期一致
*/
publicvoidaddToBlacklist(String token, long expireSeconds) {
Stringkey= BLACKLIST_PREFIX + token;
stringRedisTemplate.opsForValue().set(key, "1", expireSeconds, TimeUnit.SECONDS);
}
/**
* 检查 Token 是否在黑名单中
* @param token JWT Token
* @return true=已失效,false=有效
*/
publicbooleanisBlacklisted(String token) {
Stringkey= BLACKLIST_PREFIX + token;
return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key));
}
}1′′ 创建RedisKey常量类
package com.mall.common.constant;
/**
* Redis 缓存 Key 常量
*/
publicclassRedisKeyConstants {
privateRedisKeyConstants() {
}
/**
* Token 黑名单前缀
*/
publicstaticfinalStringTOKEN_BLACKLIST_PREFIX="token:blacklist:";
}
2′′ 创建 TokenBlacklistService 类
package com.mall.common.service;
import com.mall.common.constant.RedisKeyConstants;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* Token 黑名单服务
* 用于实现用户退出登录功能
*
* 当用户退出登录时,把TA的 JWT Token 放进 Redis 黑名单里。
* 这样即使这个 Token 还没过期,后续拿着它来访问接口也会被拦截,
* 相当于让 Token 提前失效
*/
@Service
@RequiredArgsConstructor
publicclassTokenBlacklistService {
privatefinal StringRedisTemplate stringRedisTemplate;
/**
* 将 Token 加入黑名单
* @param token JWT Token
* @param expireSeconds 过期时间(秒),应与 Token 剩余有效期一致
*/
publicvoidaddToBlackList(String token, long expireSeconds) {
Stringkey= RedisKeyConstants.TOKEN_BLACKLIST_PREFIX + token;
stringRedisTemplate.opsForValue().set(key, "1", expireSeconds, TimeUnit.SECONDS);
}
/**
* 检查 Token 是否在黑名单中
* @param token JWT Token
* @return true=已失效,false=有效
*/
publicbooleanisBlacklisted(String token) {
Stringkey= RedisKeyConstants.TOKEN_BLACKLIST_PREFIX + token;
return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key));
}
}
最后部分:stringRedisTemplate.hasKey(key) 返回的是Boolean,有可能是null
3′ 修改 AuthenticationInterceptor
// 检查 Token 是否已被加入黑名单(用户已退出登录)
if (tokenBlacklistService.isBlacklisted(token)) {
sendUnauthorizedResponse(response);
returnfalse;
}
LonguserId= jwtUtil.getUserId(token);
Stringusername= jwtUtil.getUsername(token);
request.setAttribute("userId", userId);
request.setAttribute("username", username);4′ 修改 JwtUtil
新增一个方法,用于获取 Token 剩余有效时间:
/**
* 获取 Token 剩余有效时间(秒)
*/
publiclonggetRemainingSeconds(String token) {
Claimsclaims= parseToken(token);
longexpirationTime= claims.getExpiration().getTime();
longnow= System.currentTimeMillis();
return Math.max(0, (expirationTime - now) / 1000);
}5′ 修改 UserService
新增 logout() 退出登录方法
/**
* 用户退出登录
* @param token JWT Token
*/
voidlogout(String token);6′ 修改 UserServiceImpl
实现 logout() 方法
@Override
publicvoidlogout(String token) {
if (token != null && jwtUtil.validateToken(token)) {
longremainingSeconds= jwtUtil.getRemainingSeconds(token);
tokenBlacklistService.addToBlackList(token, remainingSeconds);
}
}7′ 修改 UserController
新增 logout() 接口
@Operation(summary = "用户退出登录")
@PostMapping("/logout")
public Result<Void> logout() {
Stringtoken= UserContext.getToken();
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
}
userService.logout(token);
return Result.ok("退出登录成功", null);
}8′ 修改 UserContext
新增 getToken() 方法
/**
* 获取当前请求的 Token
* @return Token,未登录返回 null
*/
publicstatic String getToken() {
HttpServletRequestrequest= getRequest();
if (request != null) {
return request.getHeader(HttpHeaderConstants.AUTHORIZATION);
}
returnnull;
}9′ 新增常量类
保存请求头等常量
/**
* Http 请求头常量类
*/
publicclassHttpHeaderConstants {
privateHttpHeaderConstants() {
}
/**
* 认证请求头
*
* 用于在请求头中传递 JWT Token,格式通常为:Bearer + 空格 + Token
* 对应拦截器中的认证校验
*/
publicstaticfinalStringAUTHORIZATION="Authorization";
}
5° 修改用户普通信息
真实姓名、性别、头像等普通信息,在数据库中值可重复,共用一套修改逻辑
密码信息敏感,单独实现修改逻辑
其他信息字段的值在库中唯一存在,单独实现修改逻辑
1′ 创建性别枚举
package com.mall.common.enums;
import lombok.Getter;
/**
* 性别枚举
*/
@Getter
publicenumGender {
UNKNOWN(0, "未知"),
MALE(1, "男"),
FEMALE(2, "女");
privatefinal Integer code;
privatefinal String desc;
Gender(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
/**
* 根据 code 获取枚举
*/
publicstatic Gender fromCode(Integer code) {
if (code == null) {
returnnull;
}
for (Gender gender : values()) {
if (gender.code.equals(code)) {
return gender;
}
}
returnnull;
}
}2′ 创建 UserUpdateDTO
该dto中保存修改信息参数
package com.mall.module.user.pojo.dto.req;
import com.mall.common.enums.Gender;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Data;
import java.time.LocalDate;
/**
* 修改用户信息参数
*/
@Data
publicclassUserUpdateDTO {
/**
* 真实姓名
*/
private String realName;
/**
* 头像 URL
*/
private String avatar;
/**
* 性别:0未知 1男 2女 {@link Gender}
*/
@Min(value = 0, message = "性别值只能是0 1 2")
@Max(value = 2, message = "性别值只能是0 1 2")
private Integer gender;
/**
* 生日
*/
private LocalDate birthday;
}
3′ 修改 UserService
新增修改普通信息方法 updateUserInfo()
/**
* 修改用户普通信息
* @param userId
* @param dto
*/
voidupdateUserInfo(Long userId, UserUpdateDTO dto);4′ 修改 UserServiceImpl
实现方法 updateUserInfo()
@Override
publicvoidupdateUserInfo(Long userId, UserUpdateDTO dto) {
Useruser=newUser();
user.setId(userId);
user.setRealName(dto.getRealName());
user.setAvatar(dto.getAvatar());
user.setGender(dto.getGender());
user.setBirthday(dto.getBirthday());
userMapper.updateById(user);
}为什么不用判断 dto 的属性是否为 null,即为什么不采用下面的方式,不怕把数据库中的属性都覆盖成 null 吗?
@Override
publicvoidupdateUserInfo(Long userId, UserUpdateDTO dto) {
Useruser=newUser();
user.setId(userId);
if (dto.getRealName() != null) {
user.setRealName(dto.getRealName());
}
if (dto.getAvatar() != null) {
user.setAvatar(dto.getAvatar());
}
if (dto.getGender() != null) {
user.setGender(dto.getGender());
}
if (dto.getBirthday() != null) {
user.setBirthday(dto.getBirthday());
}
userMapper.updateById(user);
}MyBatis-Plus 的 update方法 默认不会把 null 字段更新到数据库, 即现在这两种方法完全一样。这也是实体类中 int 类型的属性类型都改为 Integer 包装类,因为 int 的默认值是0,调用 MyBatis-Plus 的 update() 方法时,不加注意便把对应字段改成0了
5′ 修改 UserController
新增修改普通信息接口
/**
* 修改用户普通信息
*/
@Operation(summary = "修改用户普通信息")
@PutMapping("/info")
public Result<Void> updateUserInfo(@Valid@RequestBody UserUpdateDTO dto) {
LongcurrentUserId= UserContext.getCurrentUserId();
userService.updateUserInfo(currentUserId, dto);
return Result.ok("修改成功", null);
}6′ 修改 UserInfoResponse
增加性别值对应的性别描述,如 1 对应 男 ,直接把"男"返回给前端,防止前端理解错误
/**
* 性别描述
*/
private String genderDesc;7′ 修改 UserServiceImpl 中的 getCurrentUser() 方法
@Override
public UserInfoResponse getCurrentUser(Long userId) {
Useruser= userMapper.selectById(userId);
if (user == null) {
thrownewBusinessException(ResultCode.USER_NOT_FOUND);
}
// 修改部分
UserInfoResponseresponse= BeanUtil.copyProperties(user, UserInfoResponse.class);
Gendergender= Gender.fromCode(user.getGender());
if (gender != null) {
response.setGenderDesc(gender.getDesc());
}
return response;
}6° 修改用户名
同下方修改手机号
7° 修改密码
仍类似下方修改手机号,不同的是 UpdatePasswordDTO 里需要有 oldPassword 跟 newPassword 两个属性
8° 修改手机号
1′ 修改正则表达式常量类
注册时可不填写手机号,修改手机号时必须提供手机号
package com.mall.common.constant;
publicclassRegexConstants {
privateRegexConstants() {
}
/**
* 用户名格式正则表达式
*/
publicstaticfinalStringUSERNAME_REGEX="^[\\u4e00-\\u9fa5a-zA-Z0-9_]+$";
/**
* 电话格式正则表达式 (不可以为空)
*/
publicstaticfinalStringPHONE_REGEX_REQUIRED="^1[3-9]\\d{9}$";
/**
* 电话格式正则表达式 (可以为空)
*/
publicstaticfinalStringPHONE_REGEX_OPTIONAL="^$|" + PHONE_REGEX_REQUIRED;
/**
* 邮箱格式正则表达式
*/
publicstaticfinalStringEMAIL_REGEX="^$|^[\\w.-]+@[\\w.-]+\\.\\w+$";
}
2′ 新增 UpdatePhoneDTO
package com.mall.module.user.pojo.dto.req;
import com.mall.common.constant.RegexConstants;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* 修改手机号请求参数
*/
@Data
publicclassUpdatePhoneDTO {
/**
* 新手机号
*/
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = RegexConstants.PHONE_REGEX_REQUIRED,
message = "手机号格式不正确"
)
private String phone;
}
3′ 修改 UserController
新增修改电话接口
/**
* 修改用户手机号
*/
@Operation(summary = "修改用户手机号")
@PutMapping("/phone")
public Result<Void> updatePhone(@Valid@RequestBody UpdatePhoneDTO dto) {
LongcurrentUserId= UserContext.getCurrentUserId();
userService.updatePhone(currentUserId, dto.getPhone());
return Result.ok("修改成功", null);
}4′ 修改 UserService 接口
/**
* 修改用户手机号
* @param currentUserId 用户Id
* @param newPhone 新手机号
*/
voidupdatePhone(Long currentUserId, String newPhone);5′ 修改 UserServiceImpl
@Override
publicvoidupdatePhone(Long userId, String newPhone) {
LambdaQueryWrapper<User> wrapper = newLambdaQueryWrapper<>();
wrapper.ne(User::getId, userId)
.eq(User::getPhone, newPhone);
if (userMapper.selectCount(wrapper) > 0) {
thrownewBusinessException(ResultCode.USER_PHONE_EXISTS);
}
// 更新手机号
Useruser=newUser();
user.setId(userId);
user.setPhone(newPhone);
userMapper.updateById(user);
}9° 修改邮箱
同上修改手机号
10° 用户注销
MyBatis-Plus 的 deleteById() 方法 搭配 @TableLogic 注解,自动实现逻辑删除,不用 update() 方法
1′ UserController 新增注销接口
/**
* 注销账号
*/
@Operation(summary = "注销账号")
@DeleteMapping
public Result<Void> deleteAccount() {
LongcurrentUserId= UserContext.getCurrentUserId();
userService.deleteAccount(currentUserId);
return Result.ok("注销成功", null);
}2′ UserService 接口中新增对应方法
/**
* 注销账号
* @param userId 用户Id
*/
voiddeleteAccount(Long userId);3′ UserServiceImpl 实现注销方法
@Override
publicvoiddeleteAccount(Long userId) {
userMapper.deleteById(userId);
}4′ 出现的问题及改进
按上述逻辑,如果一个用户注销后,由于它的 username 在数据库中是 unique 索引,所以该 username 以后永远不能被使用。但数据库中 phone 和 email 也是 unique 索引,一个账号注销了,它绑定的手机号或邮箱应该能再绑定新的账号。怎么办?
取消 unique 索引或改成普通索引?
先不考虑高并发,万一真有两三个家伙,或者一个家伙拿两部手机,恶意同时填写同一个手机号,此时需要有数据库的 unique 兜底。
改用 (phone, is_deleted)联合唯一索引?
这会导致一个账号删除后,可以用对应的手机号绑定新账号,但再删除的时候由于联合唯一索引,删除不了。
改用物理删除?
不这么干
逻辑删除 is_deleted 为0时表示未删除,非0时(不一定是1)表示删除?
无法使用 MP 原本的逻辑删除 API
在删除前,修改 unique 字段
比如在删除前,把 phone 值为 12345670000 改为123...0000_delelted_随机数,看起来可以,但会增加大量冗余,删除的人多了,会占用大量空间
最终实现
删除后的 username保留,以后 不可被重复注册,phone、email 直接设置成 null。
注意 MP 中修改字段为 null: userMapper.update(null, wrapper)
1′′修改 UserServiceImpl (最终的删除)
@Override
publicvoiddeleteAccount(Long userId) {
LambdaUpdateWrapper<User> wrapper = newLambdaUpdateWrapper<>();
wrapper.eq(User::getId, userId)
.set(User::getPhone, null)
.set(User::getEmail, null);
userMapper.update(null, wrapper);
userMapper.deleteById(userId);
}2′′ 修改注册逻辑 (绕过 @TableLogic 的自动过滤)
1′′′ 在 Mapper 中自定义查询方法
/**
* 用户 Mapper 接口
* 继承 MyBatis-Plus 的 BaseMapper,自动拥有 CRUD 方法
*/
@Mapper
publicinterfaceUserMapperextendsBaseMapper<User> {
/**
* 查询用户名是否存在(包括已删除的)
*/
@Select("SELECT COUNT(*) FROM `user` WHERE username = #{username}")
intcountByUsernameIncludeDeleted(@Param("username") String username);
}2′′′ 修改注册逻辑
/**
* 用户注册
* 流程:
* 1. 检查用户名是否已存在
* 2. 检查手机号是否已存在(如果填写了)
* 3. 检查邮箱是否已存在(如果填写了)
* 4. 对密码进行 BCrypt 加密
* 5. 保存用户信息到数据库
*/
@Override
@Transactional(rollbackFor = Exception.class)
publicvoidregister(UserRegisterDTO dto) {
try {
LambdaQueryWrapper<User> wrapper = newLambdaQueryWrapper<>();
// wrapper.eq(User::getUsername, dto.getUsername());
// if (userMapper.selectCount(wrapper) > 0) {
// throw new BusinessException(ResultCode.USER_USERNAME_EXISTS);
// }
// 检查用户名是否存在(包括已删除的)
if (userMapper.countByUsernameIncludeDeleted(dto.getUsername()) > 0) {
thrownewBusinessException(ResultCode.USER_USERNAME_EXISTS);
}
Stringphone= dto.getPhone();
if (phone != null && !phone.isEmpty()) {
wrapper = newLambdaQueryWrapper<>();
wrapper.eq(User::getPhone, phone);
if (userMapper.selectCount(wrapper) > 0) {
thrownewBusinessException(ResultCode.USER_PHONE_EXISTS);
}
}
Stringemail= dto.getEmail();
if (email != null && !email.isEmpty()) {
wrapper = newLambdaQueryWrapper<>();
wrapper.eq(User::getEmail, email);
if (userMapper.selectCount(wrapper) > 0) {
thrownewBusinessException(ResultCode.USER_EMAIL_EXISTS);
}
}
Useruser=newUser();
user.setUsername(dto.getUsername());
user.setPassword(PasswordUtil.encode(dto.getPassword()));
user.setPhone(phone);
user.setEmail(email);
user.setStatus(UserStatus.NORMAL.getCode());
userMapper.insert(user);
} catch (DuplicateKeyException e) {
thrownewBusinessException("注册失败,请稍后重试");
}
}11° 图形验证码
前端发起请求得到验证码,验证码存到 redis 缓存,前端用户注册/登录时输入验证码和 redis 中的验证码作对比。
1′ 引依赖
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>2′ 新建验证码响应类 CaptchaResponse
package com.mall.module.user.pojo.dto.resp;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 验证码响应
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
publicclassCaptchaResponse {
/**
* 验证码唯一标识
*/
private String captchaKey;
/**
* 验证码图片 Base64
*/
private String captchaImage;
}
3′ 新建验证码服务类 CaptchaService
package com.mall.common.service;
import com.mall.module.user.pojo.dto.resp.CaptchaResponse;
/**
* 验证码服务
*/
publicinterfaceCaptchaService {
/**
* 生成验证码
* @return 验证码响应
*/
CaptchaResponse generateCaptcha();
/**
* 验证验证码
* @param captchaKey 验证码唯一标识
* @param captchaCode 用户输入的验证码
* @return 是否正确
*/
booleanverifyCaptcha(String captchaKey, String captchaCode);
}
4′ 实现验证码服务
package com.mall.common.service.impl;
import com.mall.common.service.CaptchaService;
import com.mall.module.user.pojo.dto.resp.CaptchaResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.StringRedisTemplate;
import com.wf.captcha.SpecCaptcha;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 验证码服务实现
*/
@Service
@RequiredArgsConstructor
publicclassCaptchaServiceImplimplementsCaptchaService {
privatestaticfinalStringCAPTCHA_PREFIX="captcha:";
privatestaticfinallongCAPTCHA_EXPIRE=300; // 5分钟过期
privatefinal StringRedisTemplate stringRedisTemplate;
@Override
public CaptchaResponse generateCaptcha() {
// 生成验证码
SpecCaptchacaptcha=newSpecCaptcha(130, 48, 4);
Stringcode= captcha.text().toLowerCase();
// 生成唯一标识
StringcaptchaKey= UUID.randomUUID().toString();
// 存入 Redis
StringredisKey= CAPTCHA_PREFIX + captchaKey;
stringRedisTemplate.opsForValue().set(redisKey, code, CAPTCHA_EXPIRE, TimeUnit.SECONDS);
// 返回
returnnewCaptchaResponse(captchaKey, captcha.toBase64());
}
@Override
publicbooleanverifyCaptcha(String captchaKey, String captchaCode) {
if (captchaKey == null || captchaCode == null) {
returnfalse;
}
StringredisKey= CAPTCHA_PREFIX + captchaKey;
StringstoredCode= stringRedisTemplate.opsForValue().get(redisKey);
if (storedCode == null) {
returnfalse;
}
// 验证后删除,防止重复使用
stringRedisTemplate.delete(redisKey);
return storedCode.equalsIgnoreCase(captchaCode);
}
}
5′ UserController 新增获取验证码接口
/**
* 获取验证码
*/
@Operation(summary = "获取验证码")
@GetMapping("/captcha")
public Result<CaptchaResponse> getCaptcha() {
CaptchaResponsecaptcha= captchaService.generateCaptcha();
return Result.ok(captcha);
}6′ 修改注册/登录 DTO
在 UserLoginDTO、UserRegisterDTO 中新增下面字段
/**
* 验证码唯一标识
*/
@NotBlank(message = "验证码标识不能为空")
private String captchaKey;
/**
* 验证码
*/
@NotBlank(message = "验证码不能为空")
private String captchaCode;7′ 修改 UserServiceImpl 注册/登录逻辑
@Override
@Transactional(rollbackFor = Exception.class)
publicvoidregister(UserRegisterDTO dto) {
// 验证验证码
if (!captchaService.verifyCaptcha(dto.getCaptchaKey(), dto.getCaptchaCode())) {
thrownewBusinessException("验证码错误");
}
// ... 原有逻辑
}login() 方法中也加上这几行
8′ 在 ResultCode 中新增错误码
CAPTCHA_ERROR(1008, "验证码错误"),9′ 修改 WebMvcConfig
package com.mall.common.config;
import com.mall.common.interceptor.AuthenticationInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置
* 配置拦截器、跨域等
*/
@Configuration
@RequiredArgsConstructor
publicclassWebMvcConfigimplementsWebMvcConfigurer {
privatefinal AuthenticationInterceptor authenticationInterceptor;
@Override
publicvoidaddInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authenticationInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/user/register",
"/user/login",
"/user/captcha", // 新增这行
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/v3/api-docs",
"/webjars/**",
"/favicon.ico"
);
}
}10′ 验证
1′′ 获取验证码
GET http://localhost:8080/api/user/captcha
返回结果
{
"code":200,
"message":"success",
"data":{
"captchaKey":"1ec67918-4693-4134-83ff-e711859b935e",
"captchaImage":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIIAAAAwCAIAAABSYzXUAAAPVklEQVR4Xu1aCVRU1xnGNIk2e5suaZv2NKZrmpO0AdyIsSYx1kajSTSW1BjTuBGXqFGpJubYGqPGXQFFGNkFREQgCASJIJvgLDAwwDADOMyMsg3LsM0wC/3eu/B8c5kZB8TWHvnOf+bcd9+9d9783/23+8atbxR3ANzojlH8LzBKwx2BURruCIzScEdglIY7AgwNFqs1SipdcPr0xKCgedHRe3Jz1e3t9MBR3E4wNICDJQkJ8uZm8KFqawsWiV4OCxNfv06PHcVtA0PD/NjYKp2O33teoVgUH8/vGcVtB=..."
}
}2′′ 复制 captchaImage 的值
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIIAAAAwCAIAAABSYzXUAAAPVklEQVR4Xu1aCVRU1xnGNIk2e5suaZv2NKZrmpO0AdyIsSYx1kajSTSW1Bj...
打开浏览器,输入进去,出现图片验证码

3′′ 注册/登录时携带验证码
正常注册/登录
Json串中新增图片验证码信息,如下
{
"username":"test01",
"password":"123456",
"phone":"13800138005",
"captchaKey":"1ec67918-4693-4134-83ff-e711859b935e",
"captchaCode":"qbz7"
}user 模块结构

夜雨聆风