后端接口的 “数据校验” 体系:从 “垃圾数据入侵” 到 “输入净化”

114 阅读7分钟

接口作为系统与外部交互的入口,不可避免会接收各种输入数据 —— 恶意的攻击参数、格式错误的值、超出业务范围的异常数据,都可能导致系统崩溃、数据污染或业务逻辑错误。数据校验体系通过 “多层过滤”“精准校验”“友好提示” 三重机制,在数据进入业务逻辑前进行净化,是保障系统稳定和数据质量的 “第一道防线”。

数据校验的核心价值与层级划分

为什么需要数据校验?

  • 防止垃圾数据入库:避免格式错误(如手机号含字母)、逻辑矛盾(如订单金额为负)的数据污染数据库
  • 减少业务异常:提前拦截无效输入,降低下游业务逻辑的异常处理成本
  • 抵御恶意攻击:过滤 SQL 注入(如参数含' or 1=1)、XSS 攻击(如含<script>标签)等恶意输入
  • 提升用户体验:及时返回明确的错误提示(如 “手机号格式不正确”),避免用户无效操作

校验的三层防御体系

  1. 网关层校验:拦截明显非法的请求(如请求体过大、IP 黑名单)
  2. 接口层校验:验证参数格式、必填项、数据范围(如 “年龄必须大于 0”)
  3. 业务层校验:验证业务逻辑合法性(如 “库存不足,无法下单”)

接口层校验:参数合法性的直接保障

1. 基于注解的声明式校验

使用 JSR-303(Bean Validation)规范的注解,简洁高效地完成参数校验:

// 引入依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

// 请求参数类
@Data
public class UserRegisterDTO {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 4, max = 20, message = "用户名长度必须在4-20之间")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$", 
             message = "密码至少8位,包含字母和数字")
    private String password;

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

    @NotNull(message = "年龄不能为空")
    @Min(value = 0, message = "年龄不能小于0")
    @Max(value = 150, message = "年龄不能大于150")
    private Integer age;

    @Email(message = "邮箱格式不正确")
    private String email; // 非必填,但填了就必须符合格式
}

// 接口中启用校验
@RestController
@RequestMapping("/users")
public class UserController {
    @PostMapping("/register")
    public Result register(@Valid @RequestBody UserRegisterDTO dto, 
                          BindingResult bindingResult) {
        // 处理校验失败的情况
        if (bindingResult.hasErrors()) {
            // 获取第一个错误信息返回
            String errorMsg = bindingResult.getFieldError().getDefaultMessage();
            return Result.fail(400, errorMsg);
        }
        // 校验通过,执行注册逻辑
        userService.register(dto);
        return Result.success("注册成功");
    }
}

常用校验注解

  • 空值校验:@NotNull(不能为 null)、@NotBlank(字符串不能为 null 且 trim 后不为空)、@NotEmpty(集合不能为 null 且不为空)
  • 数值校验:@Min/@Max(数值范围)、@DecimalMin/@DecimalMax(小数范围)
  • 长度校验:@Size(集合 / 字符串长度)、@Length(字符串长度)
  • 格式校验:@Pattern(正则匹配)、@Email(邮箱格式)

2. 全局异常处理:统一返回校验错误

通过@ControllerAdvice统一处理校验异常,避免在每个接口中重复编写错误处理逻辑:

@RestControllerAdvice
public class GlobalExceptionHandler {
    // 处理参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result handleValidationException(MethodArgumentNotValidException e) {
        // 收集所有错误信息(也可只返回第一个)
        List<String> errorMessages = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.toList());
        return Result.fail(400, String.join(";", errorMessages));
    }

    // 处理路径参数/请求参数校验异常
    @ExceptionHandler(ConstraintViolationException.class)
    public Result handleConstraintViolationException(ConstraintViolationException e) {
        String errorMsg = e.getConstraintViolations().stream()
                .map(ConstraintViolation::getMessage)
                .findFirst()
                .orElse("参数校验失败");
        return Result.fail(400, errorMsg);
    }
}

// 接口中无需再处理BindingResult
@PostMapping("/register")
public Result register(@Valid @RequestBody UserRegisterDTO dto) {
    // 直接执行业务逻辑(校验失败会被全局异常处理器捕获)
    userService.register(dto);
    return Result.success("注册成功");
}

业务层校验:复杂逻辑的深度验证

注解校验适合简单规则,复杂的业务逻辑校验(如 “用户余额是否足够支付订单”)需要在业务层实现:

@Service
public class OrderService {
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private ProductMapper productMapper;

    public Long createOrder(OrderCreateDTO dto) {
        // 1. 业务规则校验:用户是否存在
        User user = userMapper.selectById(dto.getUserId());
        if (user == null) {
            throw new BusinessException("用户不存在");
        }

        // 2. 业务规则校验:商品是否存在且库存充足
        Product product = productMapper.selectById(dto.getProductId());
        if (product == null) {
            throw new BusinessException("商品不存在");
        }
        if (product.getStock() < dto.getQuantity()) {
            throw new BusinessException("商品库存不足,当前库存:" + product.getStock());
        }

        // 3. 业务规则校验:金额是否合理(防止负数或过大值)
        if (dto.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new BusinessException("订单金额必须大于0");
        }
        if (dto.getAmount().compareTo(new BigDecimal("1000000")) > 0) {
            throw new BusinessException("订单金额不能超过100万");
        }

        // 4. 执行下单逻辑(省略)
        return orderId;
    }
}

// 自定义业务异常
public class BusinessException extends RuntimeException {
    public BusinessException(String message) {
        super(message);
    }
}

// 全局异常处理器中添加业务异常处理
@ExceptionHandler(BusinessException.class)
public Result handleBusinessException(BusinessException e) {
    return Result.fail(400, e.getMessage());
}

特殊场景的校验处理

1. 防 SQL 注入与 XSS 攻击

对用户输入的文本进行净化,避免恶意脚本或 SQL 片段:

@Component
public class XssFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // 使用包装类处理请求参数,过滤XSS脚本
        chain.doFilter(new XssHttpServletRequestWrapper((HttpServletRequest) request), response);
    }
}

// XSS请求包装类
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {
    // 过滤HTML标签和特殊字符
    private String cleanXss(String value) {
        if (value == null) {
            return null;
        }
        // 过滤<script>等标签
        value = value.replaceAll("<script>", "&lt;script&gt;");
        value = value.replaceAll("</script>", "&lt;/script&gt;");
        // 过滤SQL注入相关字符
        value = value.replaceAll("'", "''");
        return value;
    }

    // 重写获取参数的方法,对参数值进行净化
    @Override
    public String getParameter(String name) {
        String value = super.getParameter(name);
        return cleanXss(value);
    }

    // 重写获取请求体的方法(针对JSON参数)
    @Override
    public ServletInputStream getInputStream() throws IOException {
        // 读取原始请求体
        String body = IOUtils.toString(super.getInputStream(), StandardCharsets.UTF_8);
        // 净化后写回
        byte[] cleanBody = cleanXss(body).getBytes(StandardCharsets.UTF_8);
        return new ServletInputStream() {
            @Override
            public int read() throws IOException {
                return 0;
            }

            @Override
            public boolean isFinished() {
                return false;
            }

            // 其他方法实现省略...
        };
    }
}

2. 批量数据校验

对批量提交的数据(如批量创建用户),需逐个校验并返回详细错误:

@PostMapping("/batch-register")
public Result batchRegister(@Valid @RequestBody List<UserRegisterDTO> userList) {
    List<String> errorList = new ArrayList<>();
    // 逐个校验
    for (int i = 0; i < userList.size(); i++) {
        UserRegisterDTO user = userList.get(i);
        // 使用Validator手动校验
        Set<ConstraintViolation<UserRegisterDTO>> violations = validator.validate(user);
        if (!violations.isEmpty()) {
            String errors = violations.stream()
                    .map(ConstraintViolation::getMessage)
                    .collect(Collectors.joining(";"));
            errorList.add("第" + (i+1) + "条数据错误:" + errors);
        }
    }
    if (!errorList.isEmpty()) {
        return Result.fail(400, "批量注册失败", errorList);
    }
    // 校验通过,执行批量注册
    userService.batchRegister(userList);
    return Result.success("批量注册成功");
}

校验体系的最佳实践

1. 校验规则与业务文档同步

确保校验规则在接口文档中明确说明(如 Swagger 文档):

@Data
public class UserRegisterDTO {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 4, max = 20, message = "用户名长度必须在4-20之间")
    @Schema(description = "用户名", example = "zhangsan", required = true)
    private String username;
    // 其他字段...
}

2. 校验逻辑的可复用性

将通用校验逻辑封装为工具类或自定义注解:

// 自定义注解:验证身份证号
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IdCardValidator.class)
public @interface IdCard {
    String message() default "身份证号格式不正确";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// 自定义校验器
public class IdCardValidator implements ConstraintValidator<IdCard, String> {
    // 身份证号正则(简化版)
    private static final String REGEX = "^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$";

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // 允许为null,非空校验由@NotBlank处理
        }
        return value.matches(REGEX);
    }
}

// 使用自定义注解
public class UserDTO {
    @IdCard(message = "身份证号格式错误")
    private String idCard;
}

避坑指南

  • 校验不要过度前移:复杂业务校验放在业务层而非接口层(避免接口参数类过于复杂)

  • 错误提示要具体:避免 “参数错误” 这种模糊提示,应明确指出 “手机号格式不正确”

  • 区分前端校验和后端校验:前端校验仅提升体验,后端校验才是安全保障(防止绕过前端直接调用接口)

  • 性能平衡:批量数据校验时注意性能(如 1000 条数据逐个校验,避免 O (n²) 复杂度)

数据校验体系的核心是 “在合适的层级做合适的校验”—— 网关层挡掉明显攻击,接口层保证参数合规,业务层确保逻辑正确。一个完善的校验体系,能让系统在面对各种输入时保持稳健,这是后端接口 “可靠性” 的基础保障。