mid - 注册参数校验 & 登录 · SpringBoot3

用户注册接口的参数合法性校验

注册参数校验 & 全局异常处理

由于用户输入未必是合法性字符, 所以我们需要加以约束和校验, 但是每次都手写验证代码太冗长且繁杂, 于是我们引入Spring Validation模块简化验证步骤 —> spring-boot-starter-validation 依赖

参数校验

基础校验 & 常用注解

参数前加 @Pattern注解 Controller类前添加 @Validated

以下是常用属性

属性含义
regexpregular expression,正则表达式规则
message校验失败时返回的提示信息

Not类型

注解作用适用类型特点
@NotNull值不能为 null任意对象类型只校验是否为 null,不管内容是否为空
@NotEmpty值不能为空String、集合、数组、Map不能为 null,长度或元素个数必须大于 0
@NotBlank字符串不能为空白String不能为 null,且去掉首尾空格后长度必须大于 0
输入值@NotNull@NotEmpty@NotBlank
null
""
" "
"abc"

长度数值

注解作用适用类型常用属性特点
@Size限制长度或元素个数String、集合、数组、Mapminmax通用性最强
@Min最小值限制(≥)数值类型value包含边界
@Max最大值限制(≤)数值类型value包含边界
@Range数值范围限制数值类型minmaxHibernate 提供,等价 Min+Max

正则类

注解作用适用类型常用属性特点
@Pattern用正则表达式校验格式Stringregexpmessage最通用,用户名密码手机号都能管
@Email校验邮箱格式Stringmessage语义更直接,本质也是格式校验

valid & validated

@Valid

@Valid 是 Java Bean Validation 标准里的注解,
作用是:

👉 告诉 Spring:对这个对象执行校验

一般写在方法参数前:

代码块JAVA · 1 行收起展开
public Result register(@RequestBody @Valid RegisterDTO dto)

意思是:

  • 接收到请求体
  • 封装成 RegisterDTO
  • 然后按 DTO 上的校验注解执行校验
  • 只做默认校验, 不支持分组校验

@Validated

支持分组校验 @Validated 是 Spring 提供的增强注解,
作用也是开启校验,但功能更强,支持分组校验

常见写法有两种:

写在类上

用于开启方法参数校验:

代码块JAVA · 4 行收起展开
@Validated  
@RestController  
public class UserController {  
}
写在参数上

也可以直接写在对象参数前:

代码块JAVA · 1 行收起展开
public Result register(@RequestBody @Validated RegisterDTO dto)

隐藏注解

返回用户信息时有些字段需要隐藏, 可以在实体类的相关字段上添加@JsonIgnore


它们怎么搭配

这块你可以这样写:

场景 1:普通对象校验

一般直接这样:

代码块JAVA · 1 行收起展开
public Result register(@RequestBody @Valid RegisterDTO dto)

场景 2:方法参数校验

比如直接校验普通参数:

代码块JAVA · 11 行收起展开
@Validated
@RestController
public class UserController {

    @PostMapping("/register")
    public Result register(
        @Pattern(regexp = "^\\S{5,16}$") String username,
        @Pattern(regexp = "^\\S{5,16}$") String password) {
        return Result.success();
    }
}

这里通常要在类上加 @Validated,不然方法参数上的约束可能不生效。

分组校验

  • 不分组就是默认分组, 分组可以继承!!!! 同一个对象,在不同业务场景下,校验规则不一样。

比如一个 UserDTO

  • 新增用户时:id 不需要传
  • 更新用户时:id 必须传
  • 注册时:要校验 usernamepassword

method

  • 按照需要写出多个实体类来接收前端参数, 这样太过于繁琐
  • 分组校验机制
代码块JAVA · 5 行收起展开
UserDTO
├─ username: 注册时必填      -> RegisterGroup
├─ password: 注册时必填      -> RegisterGroup
├─ id: 更新时必填            -> UpdateGroup
└─ nickname: 修改资料时可校验 -> ProfileGroup
1.定义分组接口

(无逻辑纯标签)

代码块JAVA · 5 行收起展开
public interface AddGroup {
}

public interface UpdateGroup {
}
2. 在字段注解上指定groups
代码块JAVA · 10 行收起展开
public class UserDTO {
@NotNull(message = "更新时id不能为空",groups = UpdateGroup.class)
    private Long id;

@NotBlank(message = "新增时用户名不能为空",groups =AddGroup.class)
    private String username;

@NotBlank(message = "新增时密码不能为空", groups = AddGroup.class)
    private String password;
}
3.Controller里用@Validated
代码块JAVA · 5 行收起展开
@PostMapping("/add")
public Result add
(@RequestBody @Validated(AddGroup.class) UserDTO userDTO) {
    return Result.success();
}

更新下面的接口添加@Validated(xxxGroup.class)

代码块JAVA · 5 行收起展开
@PostMapping("/update")
public Result update
(@RequestBody @Validated(UpdateGroup.class) UserDTO userDTO) {
    return Result.success();
}

自定义校验

这些场景,官方注解就不够优雅了:

  • 用户名只能是 6 到 20 位字母数字下划线
  • 手机号必须符合中国大陆格式
  • 两次密码要一致
  • 文章分类 id 必须存在
  • 不能包含敏感词 这时候就该上 自定义校验注解

定义注解

代码块JAVA · 12 行收起展开
@Documented  
@Target({ElementType.FIELD, ElementType.PARAMETER})  
@Retention(RetentionPolicy.RUNTIME)  
@Constraint(validatedBy = UsernameValidator.class)  

public @interface Username {  
	String message() default "用户名格式不正确";  
	  
	Class<?>[] groups() default {};  
	  
	Class<? extends Payload>[] payload() default {};  
}

定义校验器

代码块JAVA · 19 行收起展开
public class UsernameValidator implements ConstraintValidator<Username, String> {  
// 它校验的注解是 `Username`
// 它校验的字段类型是 `String`

  
@Override  
public void initialize(Username constraintAnnotation) {  
	// 可选:初始化注解参数  
}  

//实现ConstraintValidator<inf,type>接口并重写isValid方法
@Override  
public boolean isValid(String value, ConstraintValidatorContext context) {  
	if (value == null) {  
	return false;  
	}  
	return value.matches("^[a-zA-Z0-9_]{6,20}$");  
	}  
}

使用注解

代码块JAVA · 4 行收起展开
public class UserDTO {
    @Username
    private String username;
}

全局异常处理

由于 @Validated 校验注解失败后不会走方法的逻辑语句, 所以我们必须添加全局异常处理器进行全局异常捕捉

原理

“参数校验失败时异常在 Controller 执行前被抛出, 随后由 Spring MVC 的全局异常处理机制 根据 @ExceptionHandler 的异常类型匹配规则分发到对应的处理方法”。

异常往上抛给 DispatcherServlet

异常抛出后,请求处理链中断,
Spring MVC 不会继续执行你的业务逻辑,而是进入:

👉 异常处理流程

Spring 会去找有没有合适的 异常解析器HandlerExceptionResolver 来处理这个异常。

@RestControllerAdvice 被注册成全局异常处理器, 这是一个 面向所有 @RestController 的全局异常处理增强类

ExceptionHandler(Exception.class) 表明: 如果系统里抛出了 Exception 及其子类异常,可以交给这个方法处理

代码块JAVA · 9 行收起展开
@RestControlleAdvice //被注册为全局异常处理器
public class GlobalExceptionHandler{
   @ExceptionHandler(Exception.class)//抛出此种异常交给此方法处理
	public Result handlerException(Exception e){
		e.printStackTrace();
		return Result.error(
StringUtil.isNUllOrEmpty(e.getMessage()) ? "系统繁忙, 请稍后重试" : e.getMessage()));
	}
}

登录认证

代码块TEXT · 5 行收起展开
					登录成功一次

					后续请求不能反复查账号密码

					需要一个可验证的身份凭证

用户信息获取

一次请求里:

代码块PLAINTEXT · 1 行收起展开
拦截器 -> Controller -> Service -> DAO

你在拦截器里拿到了 userId 后面层层传参麻烦 ----> ThreadLocal.set(userId);

ThreadLocal

  • 解决同一线程调用链路中数据共享的问题,避免层层传参。
  • 给每个线程单独放一份变量副本, 不加锁 , 实现线程隔离
  • 使用完必须调用remove(), 否则可能造成内存泄漏和脏数据
  • 数据不存在ThreadLocal对象里, 而是在thread中
代码块PLAINTEXT · 3 行收起展开
Thread
 └── ThreadLocalMap
      └── entry(ThreadLocal -> value)

工具类编写

代码块JAVA · 16 行收起展开
public class ThreadLocalUtil {

    private static final ThreadLocal<Long> THREAD_LOCAL = new ThreadLocal<>();

    public static void set(Long userId) {
        THREAD_LOCAL.set(userId);
    }

    public static Long get() {
        return THREAD_LOCAL.get();
    }

    public static void remove() {
        THREAD_LOCAL.remove();
    }
}

拦截器放数据

代码块JAVA · 6 行收起展开
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    Long userId = 1L; // 假设从 token 中解析出来
    ThreadLocalUtil.set(userId);
    return true;
}

业务放数据

代码块JAVA · 1 行收起展开
Long userId = ThreadLocalUtil.get();

请求结束清理

代码块JAVA · 4 行收起展开
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    ThreadLocalUtil.remove();
}

主要流程

代码块JAVA · 10 行收起展开
	登录拦截器 preHandle
	    -> 解析 token
	    -> 拿到 userId
	    -> 存入 ThreadLocal
	
	Controller / Service
	    -> 随时 get 当前用户
	
	请求结束 afterCompletion
	    -> remove

令牌

常见做法是服务端生成一个令牌 Token 返回给前端,前端在后续请求中携带该令牌。 服务端只需要校验令牌是否合法,就能确认当前用户身份。

JWT

  • JWT = JSON Web Token
  • 为什么叫 JSON:因为载荷部分通常存的是 JSON 数据
  • 为什么叫 Token:因为它本质是令牌,代表一次身份凭证

组成

  • Header(头) : 记录令牌类型、签名算法等。 例如:{“alg”:“HS256”, “type”:“JWT”}.

  • Payload(有效载荷) : 携带一些自定义信息、默认信息等。 例如:{“id”:“1”,“username”:“Tom”}.

  • Signature(签名) : 防止Token被篡改、确保安全性。 将header、payload,并加入指定秘钥,通过指定签名算法计算.

    Pasted image 20260402185916

Base64:是一种基于64个可打印字符 A-Z a-z 0-9 + / 来表示二进制数据的编码方式

代码块JAVA · 19 行收起展开
private static final String KEY = "QueSera";

//接收业务数据, 生成token并返回
public static String getToken(Map<String,Object> claims){
	return JWT.create()
	.withClaim("claims",claims)//往JWT的payload里放自定义声明字段
	.withExpiresAt( //12个小时
		new Date(System.currentTimeMills()+1000*60*60*12))
	.sign(Algorithm.HMAC256(KEY));
}

//解析token, 验证token并返回业务数据
public static Map<String,Object> parseToken(String token){
	return JWT.require(Algorithm.HMAC256(KEY))
			  .build()
			  .verify(tokeb)
			  .getClaim("claims")
			  .asMap();
}