引言:为什么后端必须做数据校验?

作为刚入门的Java后端开发者,我曾有过一个误区:数据校验是前端的事,后端只要接收参数、处理业务逻辑就好。直到一次项目上线,因为前端校验被绕过(比如通过Postman直接发送请求、修改前端代码关闭校验、使用抓包工具篡改参数),大量非法数据涌入数据库——负数的订单金额、长度超过限制的用户名、格式错误的手机号、空的核心字段、包含特殊字符的敏感信息,不仅导致数据库数据混乱,还引发了后续业务逻辑异常(如订单金额为负导致结算错误)、统计数据失真,甚至出现了空指针报错、SQL注入的潜在风险。

后来在导师李哥的指导下我才明白:前端校验是“面子”,用于提升用户体验(即时提示错误,避免无效请求);后端校验是“里子”,是数据安全的最后一道防线,也是企业级开发的必备规范。无论前端是否做校验、校验是否被绕过,后端都必须对所有接收的参数(请求参数、表单数据、接口调用参数、第三方接口返回数据等)进行严格校验,避免非法数据进入业务层、数据库,减少系统异常,降低维护成本,同时规避数据安全风险。

本文将彻底讲透后端数据校验,从核心概念、常用校验方式,到Spring Boot整合实战、避坑技巧,再到企业级规范和延伸场景,结合真实开发场景和可复制代码,补充更多细节知识点和实战案例,新手也能轻松上手,快速掌握后端数据校验的核心用法,应对各类开发场景。

一、核心认知:后端数据校验是什么?校验什么?

1. 什么是后端数据校验?

后端数据校验,是指在后端接收前端请求、处理业务逻辑之前,对请求参数的合法性、完整性、格式正确性、安全性进行全面检查,不符合规则的参数直接拒绝处理,返回清晰的错误提示,确保只有合法、安全的数据能进入后续业务流程(如数据库操作、业务计算、接口调用)。

核心目的:过滤非法数据、防范数据安全风险,保障数据安全和业务逻辑正常运行,减少异常报错和数据冗余,降低系统维护成本,提升系统健壮性。

补充说明:后端数据校验不仅针对前端传入的参数,还包括第三方接口返回的数据、定时任务触发的参数、系统内部接口调用的参数,只要是进入业务层的各类数据,都需要进行校验,做到“凡数据必校验”。

2. 后端数据校验的核心范围(必校验项)

李哥告诉我,后端数据校验不是“眉毛胡子一把抓”,重点关注以下5类参数,覆盖95%以上的开发场景,每个类别补充具体校验细节和实战注意事项:

校验类型 说明 示例 补充注意事项
非空校验 核心必填字段,不允许为空(null、空字符串、纯空格字符串) 用户名、密码、订单ID、用户ID、接口必填参数 区分“允许空”和“允许空字符串”,如备注字段可允许空,但不允许纯空格;核心字段必须严格非空
格式校验 参数格式符合业务规则、行业标准或系统约定 手机号必须是11位数字、邮箱符合xxx@xxx.xxx格式、身份证号符合18位(含X)格式、日期符合yyyy-MM-dd格式 格式校验需结合正则表达式或专业工具类,避免正则漏洞(如手机号正则需兼容新号段)
长度校验 字符串、数组、集合的长度在指定范围,数值的位数(整数位、小数位)符合要求 用户名长度2-20位、密码长度6-18位、订单备注不超过500字、金额保留2位小数 字符串需区分“字节长度”和“字符长度”(如中文占2个字节),避免因字符编码导致校验失效
数值校验 数值在指定范围(正数、负数、区间),数值类型符合要求(整数、小数) 年龄1-120岁、订单金额≥0、数量≥1、折扣0-1之间、手机号为纯数字(无字母/特殊字符) 注意数值溢出问题,如金额字段建议用BigDecimal,避免用double/float导致精度丢失
自定义校验 符合业务自定义规则,无法用内置校验满足的场景 密码必须包含字母+数字+特殊字符、两次密码一致、身份证号实名校验、用户名不能包含敏感词、订单状态流转合法 自定义校验需兼顾灵活性和性能,避免复杂逻辑嵌入校验器

3. 后端数据校验的核心原则

  1. 全面性:所有前端传入、第三方接口返回、系统内部传递的参数,都必须校验;无差别对待所有数据,不遗漏任何一个核心参数和非核心参数(非核心参数也可能引发异常)。

  2. 独立性:后端校验不依赖前端,即使前端未做校验、校验被绕过(如抓包篡改参数),后端也能独立拦截非法数据,不依赖任何前端传递的校验标识。

  3. 明确性:校验失败时,返回清晰、具体的错误提示(明确哪个参数、违反了什么规则、正确格式是什么),方便前端提示用户、后端排查问题,避免模糊提示(如“参数错误”)。

  4. 高效性:校验逻辑尽量简单、高效,避免复杂计算、数据库查询、远程接口调用,不影响接口响应速度;复杂校验逻辑需放在业务层,而非校验层。

  5. 一致性:同一类型参数的校验规则保持一致(如所有手机号校验用同一正则、所有密码长度要求一致),避免出现规则混乱,降低维护成本。

  6. 安全性:校验过程中需防范数据安全风险,如过滤特殊字符(避免SQL注入、XSS攻击)、校验敏感信息格式(如身份证号、手机号脱敏前的格式校验)。

二、后端数据校验的3种常用方式(从简单到规范)

后端数据校验有3种主流方式,从简单的手动校验,到Spring提供的注解校验,再到自定义校验,适用于不同场景。李哥帮我梳理了每种方式的用法、优缺点、实战细节和延伸场景,新手可根据项目场景(简单接口、复杂业务、企业级项目)选择合适的方式,也可结合使用。

方式1:手动校验(入门级,适用于简单场景)

手动校验是最基础的方式,通过if-else判断参数是否符合规则,无需依赖任何框架,适合参数较少、校验逻辑简单的场景(如单个接口、简单表单、临时接口、小型demo项目),也可用于复杂校验逻辑的补充。

实战示例(Spring Boot接口)
package com.example.demo.controller;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;

@RestController
@RequestMapping("/user")
public class UserController {

    // 正则表达式(提取为常量,便于维护)
    private static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
    private static final String PHONE_REGEX = "^1[3-9]\\d{9}$";
    // 敏感词(示例,实际可从配置文件或数据库读取)
    private static final String[] SENSITIVE_WORDS = {"敏感词1", "敏感词2"};

    // 注册接口(手动校验参数)
    @PostMapping("/register")
    public Map<String, Object> register(@RequestBody User user) {
        Map<String, Object> result = new HashMap<>();

        // 1. 非空校验
        if (user.getUsername() == null || user.getUsername().trim().isEmpty()) {
            result.put("code", 400);
            result.put("msg", "用户名不能为空,且不能为纯空格");
            return result;
        }
        if (user.getPassword() == null || user.getPassword().trim().isEmpty()) {
            result.put("code", 400);
            result.put("msg", "密码不能为空,且不能为纯空格");
            return result;
        }
        if (user.getEmail() == null || user.getEmail().trim().isEmpty()) {
            result.put("code", 400);
            result.put("msg", "邮箱不能为空,且不能为纯空格");
            return result;
        }
        // 非必填参数,校验格式(如手机号,有值则校验格式)
        if (user.getPhone() != null && !user.getPhone().trim().isEmpty()) {
            if (!Pattern.matches(PHONE_REGEX, user.getPhone().trim())) {
                result.put("code", 400);
                result.put("msg", "手机号格式不正确,需为11位有效数字");
                return result;
            }
        }
        // 数值参数校验(年龄、金额)
        if (user.getAge() == null) {
            result.put("code", 400);
            result.put("msg", "年龄不能为空");
            return result;
        }
        if (user.getAge() < 1 || user.getAge() > 120) {
            result.put("code", 400);
            result.put("msg", "年龄必须在1-120岁之间");
            return result;
        }
        if (user.getBalance() == null) {
            result.put("code", 400);
            result.put("msg", "初始余额不能为空");
            return result;
        }
        if (user.getBalance().compareTo(BigDecimal.ZERO) < 0) {
            result.put("code", 400);
            result.put("msg", "初始余额不能为负数");
            return result;
        }

        // 2. 长度校验
        String username = user.getUsername().trim();
        if (username.length() < 2 || username.length() > 20) {
            result.put("code", 400);
            result.put("msg", "用户名长度必须在2-20位之间(中文算1位)");
            return result;
        }
        // 密码字节长度校验(中文占2个字节,避免密码过短)
        if (user.getPassword().getBytes().length < 6 || user.getPassword().getBytes().length > 18) {
            result.put("code", 400);
            result.put("msg", "密码长度必须在6-18字节之间(中文占2字节)");
            return result;
        }

        // 3. 格式校验(邮箱)
        if (!Pattern.matches(EMAIL_REGEX, user.getEmail().trim())) {
            result.put("code", 400);
            result.put("msg", "邮箱格式不正确,示例:xxx@xxx.com");
            return result;
        }

        // 4. 自定义敏感词校验
        for (String sensitiveWord : SENSITIVE_WORDS) {
            if (username.contains(sensitiveWord)) {
                result.put("code", 400);
                result.put("msg", "用户名包含敏感词,请修改");
                return result;
            }
        }

        // 校验通过,处理注册业务
        result.put("code", 200);
        result.put("msg", "注册成功");
        return result;
    }

    // 静态内部类(模拟用户参数)
    static class User {
        private String username;
        private String password;
        private String email;
        private String phone; // 非必填
        private Integer age;
        private BigDecimal balance; // 初始余额

        // get/set方法
        public String getUsername() { return username; }
        public void setUsername(String username) { this.username = username; }
        public String getPassword() { return password; }
        public void setPassword(String password) { this.password = password; }
        public String getEmail() { return email; }
        public void setEmail(String email) { this.email = email; }
        public String getPhone() { return phone; }
        public void setPhone(String phone) { this.phone = phone; }
        public Integer getAge() { return age; }
        public void setAge(Integer age) { this.age = age; }
        public BigDecimal getBalance() { return balance; }
        public void setBalance(BigDecimal balance) { this.balance = balance; }
    }
}
手动校验的延伸用法
  1. 提取校验工具类:当多个接口需要相同的校验逻辑(如手机号、邮箱校验),可将校验逻辑提取为工具类,避免代码冗余,示例:
package com.example.demo.util;

import java.math.BigDecimal;
import java.util.regex.Pattern;

public class ValidationUtil {
    // 正则表达式常量
    private static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
    private static final String PHONE_REGEX = "^1[3-9]\\d{9}$";

    // 手机号校验
    public static boolean isPhoneValid(String phone) {
        if (phone == null || phone.trim().isEmpty()) {
            return false;
        }
        return Pattern.matches(PHONE_REGEX, phone.trim());
    }

    // 邮箱校验
    public static boolean isEmailValid(String email) {
        if (email == null || email.trim().isEmpty()) {
            return false;
        }
        return Pattern.matches(EMAIL_REGEX, email.trim());
    }

    // 金额校验(非负)
    public static boolean isAmountValid(BigDecimal amount) {
        if (amount == null) {
            return false;
        }
        return amount.compareTo(BigDecimal.ZERO) >= 0;
    }
}
  1. 结合异常处理:手动校验失败时,可抛出自定义异常,由全局异常处理器统一捕获,避免每个接口都返回Map,简化代码(后续全局异常处理章节详细说明)。
优缺点
  • 优点:简单易懂,无需依赖任何框架,灵活度极高(可自定义任意复杂校验逻辑),开发成本低(适合小型项目),调试方便;

  • 缺点:代码冗余,大量if-else占据业务代码,维护成本高,不适用于参数较多、校验逻辑复杂的场景(如企业级项目的多字段表单),容易出现校验逻辑遗漏,可复用性差。

方式2:JSR380注解校验(主流,适用于大部分场景)

JSR380是Java官方定义的一套数据校验规范(JSR是Java Specification Requests的缩写,即Java规范请求),提供了一系列常用的校验注解(如@NotNull、@NotBlank、@Email等),Spring Boot已默认集成该规范(依赖spring-boot-starter-web即可,无需额外导入依赖),能大幅简化校验代码,实现校验逻辑与业务逻辑分离,是企业开发中最常用的方式,适用于90%以上的常规校验场景。

补充说明:JSR380的实现框架主要有Hibernate Validator(最常用,Spring Boot默认集成),除了实现官方定义的注解,还扩展了一些实用注解(如@NotBlank、@Length等),后续实战均基于Hibernate Validator。

1. 常用校验注解(必记)

整理了开发中最常用的15个注解(含官方注解和Hibernate扩展注解),分类别说明,无需死记硬背,用到时查阅即可,补充注解细节和使用注意事项:

类别 注解 作用 适用类型 示例 注意事项
非空校验 @NotNull 校验参数不为null 所有类型(基本类型、引用类型) @NotNull(message = “用户ID不能为空”) 对基本类型无效(如int,默认值为0,永远不为null),需用包装类型
非空校验 @NotBlank 校验字符串不为null、且去除空格后不为空 String @NotBlank(message = “用户名不能为空”) 仅适用于String,自动去除首尾空格后校验
非空校验 @NotEmpty 校验字符串不为null、集合/数组不为空(长度>0) String、Collection、数组 @NotEmpty(message = “角色列表不能为空”) String类型仅校验非null和长度>0,不处理纯空格
格式校验 @Email 校验字符串符合邮箱格式 String @Email(message = “邮箱格式不正确”) 可通过regexp属性自定义邮箱正则,适配特殊邮箱格式
格式校验 @Pattern 校验字符串符合指定正则表达式 String @Pattern(regexp = “^1[3-9]\d{9}$”, message = “手机号格式不正确”) 正则表达式需准确,避免漏洞,可提取为常量
长度校验 @Length 校验字符串长度在指定范围 String @Length(min = 2, max = 20, message = “用户名长度2-20位”) 按字符长度计算,中文算1位
长度校验 @Size 校验集合、数组、字符串的长度在指定范围 String、Collection、数组 @Size(min = 1, max = 5, message = “角色列表需1-5个角色”) 适用于多类型,String类型与@Length功能类似
数值校验 @Min 校验数值不小于指定值 数值类型(Integer、Long、BigDecimal等) @Min(value = 1, message = “年龄不能小于1岁”) 支持整数和小数,需用包装类型
数值校验 @Max 校验数值不大于指定值 数值类型 @Max(value = 120, message = “年龄不能大于120岁”) 支持整数和小数,需用包装类型
数值校验 @Positive 校验数值为正数(大于0) 数值类型 @Positive(message = “订单金额必须为正数”) 不包含0,若需包含0用@PositiveOrZero
数值校验 @Negative 校验数值为负数(小于0) 数值类型 @Negative(message = “退款金额必须为负数”) 不包含0,若需包含0用@NegativeOrZero
数值校验 @DecimalMin 校验小数不小于指定值(支持小数参数) 数值类型(主要用于BigDecimal) @DecimalMin(value = “0.01”, message = “订单金额不能小于0.01”) 适合需要精确小数的场景(如金额)
数值校验 @DecimalMax 校验小数不大于指定值(支持小数参数) 数值类型(主要用于BigDecimal) @DecimalMax(value = “999999.99”, message = “订单金额不能超过999999.99”) 适合需要精确小数的场景(如金额)
日期校验 @Past 校验日期是过去的时间 日期类型(LocalDateTime、Date) @Past(message = “创建时间必须是过去的时间”) 不包含当前时间,若需包含用@PastOrPresent
日期校验 @Future 校验日期是未来的时间 日期类型 @Future(message = “预约时间必须是未来的时间”) 不包含当前时间,若需包含用@FutureOrPresent
2. 实战示例(Spring Boot整合注解校验)

无需复杂配置,只需3步,即可实现参数校验,多场景适配、非必填参数校验、注解组合使用等细节:

步骤1:实体类添加校验注解
package com.example.demo.entity;

import jakarta.validation.constraints.*;
import lombok.Data;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
public class UserDTO { // DTO:数据传输对象,用于接收前端请求参数
    // 新增时无需校验ID,修改时必须校验ID(后续分组校验会用到)
    private Long id;

    // 非空校验+长度校验(组合使用)
    @NotBlank(message = "用户名不能为空,且不能为纯空格")
    @Length(min = 2, max = 20, message = "用户名长度必须在2-20位之间(中文算1位)")
    @Pattern(regexp = "^[a-zA-Z0-9\\u4e00-\\u9fa5]+$", message = "用户名仅支持中文、字母、数字")
    private String username;

    // 非空校验+长度校验(密码用字节长度校验,适配中文)
    @NotBlank(message = "密码不能为空,且不能为纯空格")
    @Size(min = 6, max = 18, message = "密码长度必须在6-18字节之间(中文占2字节)")
    private String password;

    // 非空校验+邮箱格式校验(自定义邮箱正则,适配企业邮箱)
    @NotBlank(message = "邮箱不能为空,且不能为纯空格")
    @Email(regexp = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$", message = "邮箱格式不正确,示例:xxx@xxx.com")
    private String email;

    // 非必填参数:有值则校验格式(手机号)
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确,需为11位有效数字", groups = {ValidateGroup.PhoneCheck.class})
    private String phone;

    // 非空校验+数值范围校验(年龄)
    @NotNull(message = "年龄不能为空")
    @Min(value = 1, message = "年龄不能小于1岁")
    @Max(value = 120, message = "年龄不能大于120岁")
    private Integer age;

    // 非空校验+金额校验(精确到2位小数)
    @NotNull(message = "初始余额不能为空")
    @DecimalMin(value = "0.00", message = "初始余额不能为负数")
    @DecimalMax(value = "999999.99", message = "初始余额不能超过999999.99")
    private BigDecimal balance;

    // 日期校验(创建时间必须是过去的时间)
    @NotNull(message = "创建时间不能为空")
    @Past(message = "创建时间必须是过去的时间")
    private LocalDateTime createTime;

    // 分组校验接口(提前定义,后续使用)
    public interface ValidateGroup {
        // 手机号校验分组(非必填,有值则校验)
        interface PhoneCheck {}
        // 新增分组
        interface AddGroup {}
        // 修改分组
        interface UpdateGroup {}
    }
}
步骤2:接口添加@Valid/@Validated注解(触发校验)

在接口的参数前添加@Valid或@Validated注解,告诉Spring Boot对该参数进行校验,两者区别:

  • @Valid:JSR380官方注解,仅触发基础校验,不支持分组校验;

  • @Validated:Spring扩展注解,支持分组校验、批量校验,功能更强大,推荐使用。

package com.example.demo.controller;

import com.example.demo.entity.UserDTO;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/user")
public class UserController {

    // 注册接口(注解校验,非必填参数校验+分组校验)
    @PostMapping("/register")
    public Map<String, Object> register(
            // @Validated指定分组,触发手机号校验(有值则校验)
            @Validated(UserDTO.ValidateGroup.PhoneCheck.class) 
            @RequestBody UserDTO userDTO, 
            BindingResult bindingResult) {
        Map<String, Object> result = new HashMap<>();

        // 校验失败:获取所有错误信息并返回(优化,之前只返回第一个)
        if (bindingResult.hasErrors()) {
            StringBuilder errorMsg = new StringBuilder();
            bindingResult.getFieldErrors().forEach(fieldError -> {
                // 拼接错误信息:参数名 + 错误提示
                errorMsg.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(";");
            });
            // 去除最后一个分号
            String msg = errorMsg.toString().substring(0, errorMsg.length() - 1);
            result.put("code", 400);
            result.put("msg", msg);
            return result;
        }

        // 校验通过,处理注册业务
        result.put("code", 200);
        result.put("msg", "注册成功");
        result.put("data", userDTO);
        return result;
    }

    // 修改接口(分组校验,仅校验修改时必填的参数)
    @PostMapping("/update")
    public Map<String, Object> update(
            @Validated(UserDTO.ValidateGroup.UpdateGroup.class)
            @RequestBody UserDTO userDTO,
            BindingResult bindingResult) {
        Map<String, Object> result = new HashMap<>();
        if (bindingResult.hasErrors()) {
            String errorMsg = bindingResult.getFieldError().getDefaultMessage();
            result.put("code", 400);
            result.put("msg", errorMsg);
            return result;
        }
        // 处理修改业务
        result.put("code", 200);
        result.put("msg", "修改成功");
        return result;
    }
}
步骤3:测试校验效果

通过Postman发送请求,测试以下场景,验证校验效果:

  1. 核心参数为空(如用户名为空、年龄为空):返回对应错误提示;

  2. 参数格式错误(如邮箱为xxx、手机号为10位):返回格式错误提示;

  3. 参数长度超标(如用户名为1位、密码为5字节):返回长度错误提示;

  4. 数值异常(如年龄为0、余额为负数):返回数值错误提示;

  5. 非必填参数有值但格式错误(如手机号为10位):返回对应错误提示;

  6. 多参数错误(如用户名过短+邮箱格式错误):返回所有错误提示。

3. 进阶:全局异常处理(优化错误返回)

上面的示例中,每个接口都需要添加BindingResult获取错误信息,代码仍有冗余。李哥教我用Spring Boot的全局异常处理,统一捕获校验异常,返回标准化的错误响应,无需在每个接口中处理,同时补充自定义业务异常的处理,适配更多场景。

步骤1:定义自定义业务异常
package com.example.demo.exception;

import lombok.Getter;

// 自定义业务异常(用于业务层校验失败)
@Getter
public class BusinessException extends RuntimeException {
    // 错误码
    private final Integer code;
    // 错误提示
    private final String msg;

    public BusinessException(Integer code, String msg) {
        super(msg);
        this.code = code;
        this.msg = msg;
    }

    // 静态方法,简化异常抛出
    public static void throwException(Integer code, String msg) {
        throw new BusinessException(code, msg);
    }
}
步骤2:编写全局异常处理器
package com.example.demo.config;

import com.example.demo.exception.BusinessException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

// 全局异常处理器(拦截所有Controller的异常)
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 捕获参数校验异常(@Valid/@Validated触发的异常,POST请求JSON参数)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Map<String, Object> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        Map<String, Object> result = new HashMap<>();
        // 获取所有错误信息,拼接返回
        StringBuilder errorMsg = new StringBuilder();
        for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
            errorMsg.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(";");
        }
        String msg = errorMsg.toString().substring(0, errorMsg.length() - 1);
        result.put("code", 400);
        result.put("msg", msg);
        result.put("data", null);
        return result;
    }

    // 捕获参数校验异常(表单提交、GET请求参数)
    @ExceptionHandler(BindException.class)
    public Map<String, Object> handleBindException(BindException e) {
        Map<String, Object> result = new HashMap<>();
        String errorMsg = e.getBindingResult().getFieldError().getDefaultMessage();
        result.put("code", 400);
        result.put("msg", errorMsg);
        result.put("data", null);
        return result;
    }

    // 捕获自定义业务异常(业务层校验失败)
    @ExceptionHandler(BusinessException.class)
    public Map<String, Object> handleBusinessException(BusinessException e) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", e.getCode());
        result.put("msg", e.getMsg());
        result.put("data", null);
        return result;
    }

    // 捕获其他所有异常(系统异常)
    @ExceptionHandler(Exception.class)
    public Map<String, Object> handleException(Exception e) {
        Map<String, Object> result = new HashMap<>();
        // 打印异常堆栈,便于排查问题(生产环境可替换为日志输出)
        e.printStackTrace();
        result.put("code", 500);
        result.put("msg", "系统异常,请联系管理员");
        result.put("data", null);
        return result;
    }
}
步骤3:优化接口代码(移除BindingResult)

优化后,接口中无需再添加BindingResult,代码更简洁,同时支持业务层抛出异常,由全局异常处理器统一处理:

@PostMapping("/register")
public Map<String, Object> register(
        @Validated(UserDTO.ValidateGroup.PhoneCheck.class) 
        @RequestBody UserDTO userDTO) {
    Map<String, Object> result = new HashMap<>();
    // 业务层校验(示例:用户名是否已存在)
    boolean usernameExists = checkUsernameExists(userDTO.getUsername());
    if (usernameExists) {
        // 抛出自定义业务异常,由全局异常处理器捕获
        BusinessException.throwException(400, "用户名已存在,请更换");
    }
    // 校验通过,处理注册业务
    result.put("code", 200);
    result.put("msg", "注册成功");
    result.put("data", userDTO);
    return result;
}

// 模拟业务层校验:用户名是否已存在
private boolean checkUsernameExists(String username) {
    // 实际场景中查询数据库
    return false; // 模拟不存在
}
4. 注解校验的延伸用法
(1)分组校验进阶(适配多场景)

同一个DTO可能用于多个接口(如新增、修改、查询),不同接口的校验规则不同,可通过分组校验实现,分组继承、多分组组合使用:

// 1. 定义分组接口(支持继承)
public interface ValidateGroup {
    // 基础分组(所有接口都需要校验的参数)
    interface BaseGroup {}
    // 新增分组(继承基础分组,新增额外校验)
    interface AddGroup extends BaseGroup {}
    // 修改分组(继承基础分组,新增额外校验)
    interface UpdateGroup extends BaseGroup {}
}

// 2. 实体类添加分组校验
@Data
public class UserDTO {
    // 基础分组:无需校验(所有接口都无需校验)
    private String remark;
    // 基础分组:必须校验(所有接口都需要校验)
    @NotBlank(message = "用户名不能为空", groups = ValidateGroup.BaseGroup.class)
    private String username;
    // 新增分组:必须校验(仅新增接口需要)
    @NotBlank(message = "密码不能为空", groups = ValidateGroup.AddGroup.class)
    private String password;
    // 修改分组:必须校验(仅修改接口需要)
    @NotNull(message = "用户ID不能为空", groups = ValidateGroup.UpdateGroup.class)
    private Long id;
}

// 3. 接口指定分组
// 新增接口:校验基础分组+新增分组
@PostMapping("/add")
public Map<String, Object> add(@Validated({ValidateGroup.BaseGroup.class, ValidateGroup.AddGroup.class}) @RequestBody UserDTO userDTO) {
    // 新增逻辑
}

// 修改接口:校验基础分组+修改分组
@PostMapping("/update")
public Map<String, Object> update(@Validated({ValidateGroup.BaseGroup.class, ValidateGroup.UpdateGroup.class}) @RequestBody UserDTO userDTO) {
    // 修改逻辑
}
(2)批量校验(集合参数校验)

当接口接收集合参数(如批量新增用户、批量修改订单)时,需对集合中的每个元素进行校验,只需在集合前添加@Valid注解即可:

// 批量注册接口(校验集合中的每个UserDTO)
@PostMapping("/batchRegister")
public Map<String, Object> batchRegister(@Valid @RequestBody List<UserDTO> userDTOList) {
    Map<String, Object> result = new HashMap<>();
    // 校验通过,处理批量注册业务
    result.put("code", 200);
    result.put("msg", "批量注册成功");
    return result;
}
(3)请求参数校验(GET请求、路径参数)

注解校验不仅适用于JSON参数,也适用于GET请求的请求参数、路径参数,只需在参数前添加对应注解,并在Controller类上添加@Validated注解:

@RestController
@RequestMapping("/user")
@Validated // 必须添加,否则GET请求参数校验不生效
public class UserController {

    // 路径参数校验(用户ID)
    @GetMapping("/{id}")
    public Map<String, Object> getUserById(
            @PathVariable 
            @NotNull(message = "用户ID不能为空") 
            @Min(value = 1, message = "用户ID必须为正数") 
            Long id) {
        Map<String, Object> result = new HashMap<>();
        // 处理查询逻辑
        result.put("code", 200);
        result.put("msg", "查询成功");
        return result;
    }

    // GET请求参数校验(分页查询)
    @GetMapping("/list")
    public Map<String, Object> getUserList(
            @RequestParam 
            @NotNull(message = "页码不能为空") 
            @Min(value = 1, message = "页码必须为正数") 
            Integer pageNum,
            @RequestParam 
            @NotNull(message = "每页条数不能为空") 
            @Min(value = 1, message = "每页条数必须为正数") 
            @Max(value = 100, message = "每页条数不能超过100") 
            Integer pageSize) {
        Map<String, Object> result = new HashMap<>();
        // 处理分页查询逻辑
        result.put("code", 200);
        result.put("msg", "查询成功");
        return result;
    }
}
优缺点
  • 优点:代码简洁,无需手动写if-else,校验逻辑与业务逻辑分离,维护成本低,适用于大部分场景(企业级项目主流选择);支持分组校验、批量校验、多场景适配,可扩展性强;

  • 缺点:无法满足复杂的自定义校验场景(如密码必须包含字母+数字+特殊字符、两次密码一致、身份证号实名校验);对复杂业务逻辑的校验支持不足,需结合自定义校验或业务层校验。

方式3:自定义校验(进阶,适用于复杂业务场景)

当JSR380的内置注解无法满足业务需求时,就需要自定义校验。比如:密码必须包含字母+数字+特殊字符、两次密码一致、身份证号符合校验规则(含校验码)、用户名不能包含敏感词、订单状态流转合法、自定义数值范围(如0-100且为偶数)等场景,都需要通过自定义校验实现。

自定义校验需要两步:创建自定义校验注解 + 实现校验逻辑(Validator),注解参数、校验逻辑优化、多场景自定义校验案例,适配企业级复杂业务需求。

实战示例1:自定义密码校验(必须包含字母+数字+特殊字符)
步骤1:创建自定义校验注解
package com.example.demo.validator;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;

// 注解作用范围:字段、方法参数
@Target({ElementType.FIELD, ElementType.PARAMETER})
// 注解生命周期:运行时
@Retention(RetentionPolicy.RUNTIME)
// 指定校验器(后续实现)
@Constraint(validatedBy = PasswordValidator.class)
public @interface PasswordCheck {

    // 校验失败提示信息(默认值)
    String message() default "密码必须包含字母、数字和特殊字符,长度6-18位";

    // 是否需要特殊字符(可配置,默认需要)
    boolean needSpecialChar() default true;

    // 最小长度(可配置,默认6)
    int minLength() default 6;

    // 最大长度(可配置,默认18)
    int maxLength() default 18;

    // 分组校验(可选,用于不同场景的校验)
    Class<?>[] groups() default {};

    // 负载信息(可选,用于传递额外信息)
    Class<? extends Payload>[] payload() default {};
}
步骤2:实现校验逻辑(Validator)
package com.example.demo.validator;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import java.util.regex.Pattern;

public class PasswordValidator implements ConstraintValidator<PasswordCheck, String> {

    // 特殊字符正则(可配置,提取为常量)
    private static final String SPECIAL_CHAR_REGEX = "[!@#$%^&*()_+-=\\[\\]{};':\"\\\\|,.<>/?]";
    // 是否需要特殊字符
    private boolean needSpecialChar;
    // 最小长度
    private int minLength;
    // 最大长度
    private int maxLength;

    // 初始化方法:读取注解中的配置参数
    @Override
    public void initialize(PasswordCheck constraintAnnotation) {
        this.needSpecialChar = constraintAnnotation.needSpecialChar();
        this.minLength = constraintAnnotation.minLength();
        this.maxLength = constraintAnnotation.maxLength();
    }

    // 核心校验逻辑:返回true表示校验通过,false表示校验失败
    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        // 1. 非空校验(可结合@NotBlank,这里无需重复,避免冗余)
        if (password == null || password.trim().isEmpty()) {
            return false;
        }
        String trimPassword = password.trim();
        // 2. 长度校验(使用注解配置的参数)
        if (trimPassword.length() < minLength || trimPassword.length() > maxLength) {
            // 自定义错误提示(覆盖默认提示,显示配置的长度)
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(
                    "密码长度必须在" + minLength + "-" + maxLength + "位之间"
            ).addConstraintViolation();
            return false;
        }
        // 3. 校验是否包含字母和数字
        boolean hasLetter = false;
        boolean hasNumber = false;
        for (char c : trimPassword.toCharArray()) {
            if (Character.isLetter(c)) {
                hasLetter = true;
            } else if (Character.isDigit(c)) {
                hasNumber = true;
            }
        }
        if (!hasLetter || !hasNumber) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(
                    "密码必须包含字母和数字"
            ).addConstraintViolation();
            return false;
        }
        // 4. 校验是否包含特殊字符(根据配置决定)
        if (needSpecialChar) {
            if (!Pattern.matches(".*" + SPECIAL_CHAR_REGEX + ".*", trimPassword)) {
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(
                        "密码必须包含特殊字符(!@#$%^&*等)"
                ).addConstraintViolation();
                return false;
            }
        }
        return true;
    }
}
步骤3:使用自定义校验注解

在实体类字段上添加自定义注解,可根据不同场景配置参数,和内置注解用法一致:

@Data
public class UserDTO {
    // 其他字段省略...

    // 场景1:默认配置(需要特殊字符,长度6-18位)
    @NotBlank(message = "密码不能为空")
    @PasswordCheck(message = "密码格式不正确")
    private String password;

    // 场景2:不需要特殊字符,长度8-20位(如后台管理员密码)
    @NotBlank(message = "后台密码不能为空")
    @PasswordCheck(
            needSpecialChar = false,
            minLength = 8,
            maxLength = 20,
            message = "后台密码必须包含字母和数字,长度8-20位"
    )
    private String adminPassword;
}
实战示例2:两次密码一致校验(如注册时的密码确认)

这种场景需要校验两个字段的关系,无法通过单个字段的注解实现,需要对整个实体类进行校验,错误提示优化、非空校验联动。

步骤1:创建自定义校验注解(作用于类)
package com.example.demo.validator;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;

// 注解作用范围:类
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordConfirmValidator.class)
public @interface PasswordConfirm {

    // 密码字段名(可配置,默认password)
    String passwordField() default "password";

    // 确认密码字段名(可配置,默认confirmPassword)
    String confirmPasswordField() default "confirmPassword";

    // 校验失败提示信息
    String message() default "两次密码不一致";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
步骤2:实现校验逻辑(校验整个实体类)
package com.example.demo.validator;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.util.ReflectionUtils;

import java.lang.reflect.Field;

public class PasswordConfirmValidator implements ConstraintValidator<PasswordConfirm, Object> {

    // 密码字段名
    private String passwordField;
    // 确认密码字段名
    private String confirmPasswordField;

    @Override
    public void initialize(PasswordConfirm constraintAnnotation) {
        // 读取注解配置的字段名
        this.passwordField = constraintAnnotation.passwordField();
        this.confirmPasswordField = constraintAnnotation.confirmPasswordField();
    }

    // 校验逻辑:通过反射获取两个字段的值,比较是否一致
    @Override
    public boolean isValid(Object obj, ConstraintValidatorContext context) {
        try {
            // 1. 通过反射获取密码字段和确认密码字段的值
            Field passwordField = obj.getClass().getDeclaredField(this.passwordField);
            Field confirmPasswordField = obj.getClass().getDeclaredField(this.confirmPasswordField);
            // 设置字段可访问(突破private修饰)
            passwordField.setAccessible(true);
            confirmPasswordField.setAccessible(true);
            // 获取字段值(String类型)
            String password = (String) passwordField.get(obj);
            String confirmPassword = (String) confirmPasswordField.get(obj);

            // 2. 非空校验(依赖@NotBlank注解,这里只需比较,避免重复校验)
            if (password == null || confirmPassword == null) {
                return false;
            }
            // 3. 比较两次密码是否一致(去除空格后比较)
            return password.trim().equals(confirmPassword.trim());
        } catch (NoSuchFieldException | IllegalAccessException e) {
            // 反射异常(字段名错误),返回校验失败
            e.printStackTrace();
            return false;
        }
    }
}
步骤3:使用注解(作用于实体类)
// 场景1:默认字段名(password和confirmPassword)
@Data
@PasswordConfirm(message = "两次密码不一致") // 作用于类
public class UserDTO {
    // 其他字段省略...

    @NotBlank(message = "密码不能为空")
    @PasswordCheck(message = "密码格式不正确")
    private String password;

    @NotBlank(message = "确认密码不能为空")
    private String confirmPassword;
}

// 场景2:自定义字段名(如pwd和repwd)
@Data
@PasswordConfirm(
        passwordField = "pwd",
        confirmPasswordField = "repwd",
        message = "两次密码不一致"
)
public class AdminDTO {
    @NotBlank(message = "密码不能为空")
    private String pwd;

    @NotBlank(message = "确认密码不能为空")
    private String repwd;
}
补充:校验失败提示优化(可选)

默认情况下,两次密码一致校验的错误提示会关联整个实体类,前端无法明确知道是哪个字段出错。可通过修改校验器,指定错误提示关联的字段(如confirmPassword),让提示更精准:

@Override
public boolean isValid(Object obj, ConstraintValidatorContext context) {
    try {
        Field passwordField = obj.getClass().getDeclaredField(this.passwordField);
        Field confirmPasswordField = obj.getClass().getDeclaredField(this.confirmPasswordField);
        passwordField.setAccessible(true);
        confirmPasswordField.setAccessible(true);
        String password = (String) passwordField.get(obj);
        String confirmPassword = (String) confirmPasswordField.get(obj);

        if (password == null || confirmPassword == null) {
            return false;
        }
        // 密码不一致时,指定错误提示关联确认密码字段
        if (!password.trim().equals(confirmPassword.trim())) {
            context.disableDefaultConstraintViolation();
            // 关联confirmPassword字段,前端可针对性提示
            context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
                    .addPropertyNode(confirmPasswordField.getName())
                    .addConstraintViolation();
            return false;
        }
        return true;
    } catch (NoSuchFieldException | IllegalAccessException e) {
        e.printStackTrace();
        return false;
    }
}
实战示例3:身份证号自定义校验(含校验码校验,企业级常用)

身份证号校验属于复杂格式校验,内置注解无法满足(需校验18位长度、前17位为数字、最后一位为数字或X,且校验码符合规则),需通过自定义校验实现,校验码算法,适配18位身份证号(15位可自行扩展)。

步骤1:创建自定义校验注解
package com.example.demo.validator;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;

// 作用于字段、方法参数
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IdCardValidator.class)
public @interface IdCardCheck {
    String message() default "身份证号格式不正确(需为18位有效身份证号)";

    // 分组校验
    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
步骤2:实现校验逻辑(含校验码算法)
package com.example.demo.validator;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class IdCardValidator implements ConstraintValidator<IdCardCheck, String> {

    // 身份证号校验码算法权重
    private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
    // 校验码对应值(0-10,10对应X)
    private static final char[] CHECK_CODES = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'};

    @Override
    public boolean isValid(String idCard, ConstraintValidatorContext context) {
        // 1. 非空校验(结合@NotBlank,避免重复)
        if (idCard == null || idCard.trim().isEmpty()) {
            return false;
        }
        String trimIdCard = idCard.trim().toUpperCase(); // 统一转为大写,适配X
        // 2. 长度校验(18位)
        if (trimIdCard.length() != 18) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("身份证号必须为18位")
                    .addConstraintViolation();
            return false;
        }
        // 3. 前17位为数字校验
        for (int i = 0; i < 17; i++) {
            char c = trimIdCard.charAt(i);
            if (!Character.isDigit(c)) {
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate("身份证号前17位必须为数字")
                        .addConstraintViolation();
                return false;
            }
        }
        // 4. 校验码校验(核心逻辑)
        int sum = 0;
        for (int i = 0; i < 17; i++) {
            // 前17位数字乘以对应权重求和
            sum += (trimIdCard.charAt(i) - '0') * WEIGHTS[i];
        }
        // 计算校验码(sum % 11 对应 CHECK_CODES 的索引)
        char checkCode = CHECK_CODES[sum % 11];
        if (trimIdCard.charAt(17) != checkCode) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("身份证号校验码错误,格式不正确")
                    .addConstraintViolation();
            return false;
        }
        // 所有校验通过
        return true;
    }
}
步骤3:使用注解
@Data
public class UserDTO {
    // 其他字段省略...

    @NotBlank(message = "身份证号不能为空")
    @IdCardCheck(message = "身份证号格式不正确,请输入18位有效身份证号")
    private String idCard;
}
自定义校验的延伸用法(企业级优化)
  1. 自定义校验结合配置文件:将敏感词、正则表达式、校验规则(如密码长度、身份证号规则)提取到application.yml配置文件,避免硬编码,便于动态修改,示例:
# application.yml
validation:
  password:
    min-length: 6
    max-length: 18
    need-special-char: true
  sensitive-words: 敏感词1,敏感词2,敏感词3
  id-card:
    length: 18
// 校验器中读取配置
@Component
public class PasswordValidator implements ConstraintValidator<PasswordCheck, String> {

    @Value("${validation.password.min-length}")
    private int minLength;
    @Value("${validation.password.max-length}")
    private int maxLength;
    @Value("${validation.password.need-special-char}")
    private boolean needSpecialChar;

    // 其余逻辑不变,使用配置文件中的参数替代硬编码
}
  1. 自定义校验分组扩展:结合JSR380的分组校验,让自定义校验适配不同场景(如新增用户需校验身份证号,修改用户无需校验),只需在注解中指定groups参数即可:
@IdCardCheck(message = "身份证号格式不正确", groups = ValidateGroup.AddGroup.class)
private String idCard;
  1. 复杂业务场景的自定义校验:如订单状态流转校验(已取消订单不能再次支付、已完成订单不能修改),需结合业务数据,可在校验器中注入Service,实现业务逻辑联动(注意:避免校验逻辑过于复杂,影响接口性能):
// 订单状态流转校验注解
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderStatusValidator.class)
public @interface OrderStatusCheck {
    String message() default "订单状态流转不合法";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// 校验器(注入Service,查询订单当前状态)
@Component
public class OrderStatusValidator implements ConstraintValidator<OrderStatusCheck, OrderDTO> {

    @Autowired
    private OrderService orderService;

    @Override
    public boolean isValid(OrderDTO orderDTO, ConstraintValidatorContext context) {
        // 1. 查询订单当前状态
        Order order = orderService.getById(orderDTO.getId());
        if (order == null) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("订单不存在")
                    .addConstraintViolation();
            return false;
        }
        // 2. 校验状态流转(如当前状态为已取消,不能修改为支付中)
        Integer currentStatus = order.getStatus();
        Integer targetStatus = orderDTO.getStatus();
        if (currentStatus == 3 && targetStatus == 2) { // 3=已取消,2=支付中
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("已取消订单不能再次支付")
                    .addConstraintViolation();
            return false;
        }
        // 其他状态流转规则...
        return true;
    }
}
自定义校验的优缺点
  • 优点:灵活性极高,可满足任何复杂业务场景的校验需求,适配企业级项目的个性化校验规则;可与JSR380注解校验结合使用,兼顾简洁性和灵活性;可扩展、可配置,复用性强。

  • 缺点:开发成本高于注解校验,需编写注解和校验器两个类;校验逻辑复杂时,可能影响接口响应速度;需注意反射、Service注入等细节,避免出现异常。

三、企业级后端数据校验规范(必遵循)

李哥强调,后端数据校验不仅要会用,还要遵循规范,避免因校验不规范导致系统隐患、维护成本增加。结合企业实际开发经验,整理了以下核心规范,覆盖校验全流程:

1. 校验层级规范(明确校验位置,避免重复校验)

后端校验分为3个层级,各司其职,避免重复校验,提升效率:

  1. 接口层校验(Controller层):负责请求参数的合法性、格式、长度、非空等基础校验,优先使用JSR380注解校验,简单高效;非必填参数有值则校验,无值则跳过。

  2. 业务层校验(Service层):负责复杂业务逻辑校验(如用户名是否已存在、订单状态流转是否合法、库存是否充足),通过自定义异常抛出校验失败信息,由全局异常处理器统一处理。

  3. 数据层校验(DAO层):负责数据库层面的校验(如唯一约束、外键约束),避免非法数据写入数据库;可结合数据库约束(如UNIQUE、NOT NULL)和MyBatis的参数校验,双重保障。

2. 错误提示规范(明确、具体,提升用户体验和排查效率)

  1. 提示信息需包含3个核心要素:参数名 + 违反的规则 + 正确格式/范围(示例:“用户名:长度必须在2-20位之间(中文算1位)”,而非“参数错误”)。

  2. 错误提示语言简洁、易懂,避免技术术语(如用户端提示“手机号格式不正确,需为11位有效数字”,而非“手机号正则匹配失败”)。

  3. 多参数错误时,返回所有错误提示(而非仅返回第一个),方便前端一次性提示用户修改,减少请求次数。

  4. 区分用户端和管理端提示:用户端提示简洁友好,管理端提示可增加更多技术细节(如“用户名包含敏感词【敏感词1】,请修改”),便于排查问题。

3. 校验性能规范(避免影响接口响应速度)

  1. 校验逻辑尽量简单,避免在校验器中执行数据库查询、远程接口调用、复杂计算(此类逻辑放在业务层)。

  2. 高频接口的校验逻辑需优化,避免重复创建校验器、重复编译正则表达式(将正则、常量提取为静态常量)。

  3. 集合参数校验(如批量新增),需控制集合大小(如单次批量不超过100条),避免因校验大量数据导致接口超时。

4. 安全性校验规范(防范数据安全风险)

  1. 过滤特殊字符:对用户名、备注、评论等字符串参数,过滤SQL注入(如’、"、union等)、XSS攻击(如

5. 代码规范(提升可维护性)

  1. 校验注解统一管理:相同类型参数的校验规则保持一致(如所有手机号用同一正则、所有密码长度要求一致),避免规则混乱。

  2. 自定义校验器统一存放:将所有自定义注解和校验器放在validator包下,便于查找和维护。

  3. 避免硬编码:将正则表达式、敏感词、校验规则(如长度、数值范围)提取为常量或配置文件,便于修改和复用。

  4. 注释规范:自定义校验器、校验工具类需添加注释,说明校验逻辑、适用场景、参数含义,便于后续维护。

四、避坑指南(新手必看)

结合我自己踩过的坑和李哥的经验,整理了后端数据校验中最常见的6个坑,避免大家重复踩坑:

  1. 坑1:混淆@NotNull、@NotBlank、@NotEmpty的用法——比如用@NotNull校验String类型(无法校验纯空格),导致纯空格参数通过校验,需根据场景选择对应注解(String类型优先用@NotBlank)。

  2. 坑2:忽略非必填参数的校验——非必填参数有值时,若格式错误,仍会导致业务异常(如手机号非必填,但传入10位数字),需实现“有值则校验,无值则跳过”。

  3. 坑3:密码用字符长度校验中文密码——中文占2个字节,若用@Length校验(按字符长度),会导致密码实际长度不足(如6个中文字符,字符长度为6,字节长度为12),需用@Size或手动校验字节长度。

  4. 坑4:金额用double/float类型校验——double/float存在精度丢失问题(如0.1+0.2≠0.3),金额校验需用BigDecimal类型,结合@DecimalMin、@DecimalMax注解。

  5. 坑5:GET请求参数校验未添加@Validated——GET请求的路径参数、请求参数,需在Controller类上添加@Validated注解,否则校验不生效。

  6. 坑6:自定义校验器中注入Service失败——自定义校验器需添加@Component注解,且校验逻辑中避免在initialize方法中使用Service(initialize方法执行时,Service可能未初始化),可在isValid方法中使用。

五、延伸场景(企业级进阶)

除了基础的接口参数校验,后端数据校验还有以下延伸场景,覆盖更多企业开发需求:

1. 第三方接口返回数据校验

调用第三方接口时,第三方返回的数据可能不规范(如字段为空、格式错误),需对返回数据进行校验,避免非法数据进入业务层。可使用JSR380注解或自定义校验,对第三方返回的DTO进行校验,校验失败则记录日志、重试或返回错误提示。

// 第三方接口返回DTO
@Data
public class ThirdPartyUserDTO {
    @NotNull(message = "第三方用户ID不能为空")
    private String thirdPartyId;

    @NotBlank(message = "第三方用户名不能为空")
    @Length(min = 2, max = 50, message = "第三方用户名长度2-50位")
    private String thirdPartyName;

    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "第三方手机号格式不正确")
    private String phone;
}

// 调用第三方接口后校验
public void handleThirdPartyData(ThirdPartyUserDTO thirdPartyUserDTO) {
    // 使用Validator手动触发校验
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();
    Set<ConstraintViolation<ThirdPartyUserDTO>> violations = validator.validate(thirdPartyUserDTO);
    if (!violations.isEmpty()) {
        // 校验失败,记录日志并处理
        StringBuilder errorMsg = new StringBuilder();
        violations.forEach(violation -> errorMsg.append(violation.getMessage()).append(";"));
        log.error("第三方接口返回数据校验失败:{}", errorMsg);
        // 重试或抛出异常
        BusinessException.throwException(500, "第三方数据异常,请稍后重试");
    }
    // 校验通过,处理业务逻辑
}

2. 定时任务参数校验

定时任务触发时,可能会传递参数(如定时同步数据的时间范围、同步数量),需对这些参数进行校验,避免定时任务执行失败。可结合手动校验或注解校验,在定时任务方法执行前进行参数校验。

3. 批量操作校验(优化)

批量操作(如批量删除、批量修改)时,除了校验集合中的每个元素,还需校验集合大小(避免批量操作过多导致系统压力)、批量操作的权限(如批量删除需管理员权限)、批量操作的合法性(如批量删除的订单不能是已支付状态)。

4. 多环境校验规则适配

开发环境、测试环境、生产环境的校验规则可能不同(如开发环境密码长度可放宽至4位,生产环境需6-18位),可通过配置文件+Profile实现多环境适配,无需修改代码。

六、总结

后端数据校验是Java后端开发的基础,也是保障系统安全和健壮性的关键,核心是“凡数据必校验、凡校验必规范”。本文从入门到进阶,讲解了3种核心校验方式,结合实战案例、企业级规范、避坑指南和延伸场景,覆盖了99%以上的开发场景:

  1. 手动校验:入门级,适用于简单接口、小型项目,灵活但冗余;

  2. JSR380注解校验:主流选择,适用于大部分场景,简洁高效,实现校验与业务分离;

  3. 自定义校验:进阶,适用于复杂业务场景,可满足个性化校验需求。

实际开发中,无需拘泥于单一方式,可结合使用(如注解校验做基础校验,自定义校验做复杂校验,手动校验做补充),同时遵循企业级规范,避开常见坑,才能写出安全、高效、易维护的校验代码。

新手建议:先掌握JSR380注解校验(覆盖大部分场景),再学习自定义校验和全局异常处理,最后熟悉企业级规范,逐步提升自己的校验能力,应对各类开发场景。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐