乐于分享
好东西不私藏

一个软件基本的用户功能实现

一个软件基本的用户功能实现

user模块

0° 接口

操作
请求方式
接口地址
登录
POST
/user/login
注册
POST
/user/register
获取用户信息
GET
/user/info
用户退出登录
POST
/user/logout
修改用户普通信息
PUT
/user/info
修改用户名
PUT
/user/username
修改密码
PUT
/user/password
绑定/修改手机号
PUT
/user/phone
绑定/修改邮箱
PUT
/user/email
用户注销
DELETE
/user
图形验证码
GET
/captcha

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. 1. Controller 接收请求
    ↓
  2. 2. 调用 Service 业务逻辑
    ↓
  3. 3. Service 发现异常
    ↓
  4. 4. throw new BusinessException(...)
    ↓
  5. 5. GlobalExceptionHandler 拦截
    ↓
  6. 6. 根据异常类型匹配对应的处理方法
    ↓
  7. 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. 1. 一个接口有多个实现类,字段注入直接报错
  2. 2. @Autowired是Spring自家定义的注解,后续如果更换容器,代码需要大范围修改,比JSR-250的@Resource、JSR-303的@Inject通用性差
  3. 3. 字段注入无法声明为final,对象可以被二次修改,安全性弱
  4. 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: 0

2′ 测试 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 模块结构

相关学习资料