订单数据验证:从购物车到支付的完整验证链路

订单数据验证:从购物车到支付的完整验证链路

📋 目录


引言

一个订单从产生到支付,要经过四个环节:购物车结算 → 填写收货信息 → 生成订单 → 发起支付。每个环节都有不同的数据要校验:

环节 典型数据 校验重点
购物车 商品 ID、数量、单价 数量范围、金额精度
收货信息 姓名、手机号、地址、邮编 格式合法性(中文姓名/手机号/邮编)
订单信息 订单号、支付方式、订单状态 订单号格式、枚举合法性
支付信息 银行卡号、CVV Luhn 算法、安全码格式

手写每一处的校验,四个环节加起来是几十段重复的 if-else + 正则。而 ValidX 恰好把每一类都封装成了现成能力------本文用一条完整链路演示:如何用注解 + 链式 API 把"购物车到支付"的校验全部串起来


一、订单数据验证全景:四段式链路

先看整条链路的校验点分布:

java 复制代码
购物车结算 ──► 填写收货信息 ──► 生成订单 ──► 发起支付

  ① 商品项              ② 收货信息                  ③ 订单信息                         ④ 支付信息
  · 数量 1-99           · @ChineseName             · 订单号 @TradeOrderNumber          · 卡号 @BankCard(Luhn)
  · 单价 @Digits(8,2)   · @ChinesePhone            · 支付方式 @In/@Enum                · 安全码 @CVV
  · 金额上限见服务层      · @ChineseZipCode          · 状态 @Enum(target=OrderStatus)    · 有效期 MM/YY @Pattern
                       · @Email

四个环节,每一类数据都能在 ValidX 找到对应注解。下面逐段实现。


二、购物车阶段:商品项验证

购物车结算时,前端传来的是一组商品项。每个商品项要验证数量金额

java 复制代码
public class CartItem {

    @NotBlank
    private String skuId;                 // 商品 SKU 必填

    @Min(value = 1, message = "商品数量至少为1")
    @Max(value = 99, message = "单件商品最多购买99件")
    private int quantity;                 // 数量:1-99

    @NotNull
    @Digits(integer = 8, fraction = 2)   // 单价:最多8位整数 + 2位小数
    private BigDecimal price;             // 单价
}

注意 :金额(BigDecimal)用标准 Bean Validation(JSR-380)的 @Digits/@Min/@Max 校验,而不是正则------金额是数值语义,正则只适合字符串格式。ValidX 的注解聚焦"格式类"校验(手机号、卡号、身份证......),数值范围类交给标准 Bean Validation 注解,两者各司其职。

quantityprice 的下限判断必须放在服务层做汇总校验(购物车为空、总价超限),因为那是业务规则,不是字段格式:

java 复制代码
if (cartItems.isEmpty()) {
    throw new BusinessException("购物车不能为空");
}
BigDecimal total = cartItems.stream()
        .map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
        .reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.compareTo(new BigDecimal("50000")) > 0) {
    throw new BusinessException("单笔订单金额不能超过5万元");
}

三、收货信息验证

结算后的下一步是收货信息。这一段的字段几乎全是"中国业务格式",正好是 ValidX 的主场:

java 复制代码
public class ShippingInfo {

    @NotBlank
    @ChineseName                 // 中文姓名:2-50个汉字,支持少数民族姓名间隔号"·"
    private String receiverName;

    @NotBlank
    @ChinesePhone                // 中国大陆手机号:11位,1[3-9]开头
    private String receiverPhone;

    @Email                       // 电子邮箱
    private String email;

    @NotBlank
    @ChineseZipCode              // 中国邮政编码:6位数字
    private String zipCode;

    @NotBlank
    private String address;       // 详细地址(自由文本,长度校验即可)
}

五个字段,四类格式注解,全部声明式搞定。每个注解都对应一个独立的 ConstraintValidator,例如 @ChinesePhone 底层是预编译的手机号格式正则(11 位、1[3-9] 开头,不枚举具体号段 ,避免新放号段被误杀)------把最容易写错的手机号正则,从业务代码里彻底移除


四、订单信息验证

订单从创建到支付,请求里反复出现的两类字段是订单号枚举类字段 (支付渠道、订单状态)。注意一个前提:订单号由后端在创建订单时生成(生成策略见第八节),创建请求本身不含它 ;需要校验格式的,是客户端回传订单号的操作(查询、支付、取消、确认收货):

java 复制代码
public class OrderSubmitDTO { // 提交/流转已生成订单:orderNo 为创建接口返回后回传

    @NotBlank
    @TradeOrderNumber            // 订单号:T+18位数字 / 18位数字 / UUID(含32位紧凑版)
    private String orderNo;

    @NotNull
    @In(value = {"WECHAT", "ALIPAY", "BANK_CARD", "BALANCE"}, message = "不支持的支付方式")
    private String payChannel;    // 支付方式:白名单校验

    @NotBlank
    @Enum(target = OrderStatus.class, field = "code")
    private String status;        // 订单状态:必须是枚举 code 之一
}

两个枚举校验各有侧重:

  • @In:给字符串白名单(支付方式列表固定,直接枚举)
  • @Enum:对齐 Java 枚举类,适合经常增删的状态机值,代码改动一处,校验自动同步

订单号这一行值得展开------@TradeOrderNumber 是 ValidX 金融分类下的专属注解,支持的格式在后面第八节详细拆解。

订单号不该由客户端生成,也不该出现在创建请求里 。创建订单成功后,订单号由后端生成并随响应下发;@TradeOrderNumber 的真正用武之地是回传校验 ------客户端拿之前下发的订单号来查询、支付、取消、确认收货时,格式校验能拦截手误与伪造。这也是为什么第五节 PayRequest、第七节 OrderDTO 里的 orderNo 校验都成立:它们都是"后端已生成、客户端回传"的语义。若某接口本应自己生成订单号却去校验客户端传入的订单号,说明接口设计就有问题------订单号字段应直接不进 DTO。

补充:回传订单号通常走 URL 路径,而不是 body 。订单号是资源标识符,RESTful 风格下操作既有订单(如发起支付)应写成 POST /orders/{orderNo}/pay,由 @PathVariable 承载,DTO 里并不需要该字段。仅三种情况才需要把它放进 body 用注解校验:非 REST 风格接口;串单防护 (路径与 body 各带一份,比对一致防止换成他人订单,@TradeOrderNumber 先过格式关、再与路径参数 equals);服务端回调(支付网关把订单号作为回调参数传回)。上面 OrderSubmitDTO 属于教学演示,实际项目中更推荐路径参数方案。


五、支付信息验证

支付环节是安全敏感区,银行卡号既要"格式对"(Luhn),也要"能用"(发卡行支持)。ValidX 负责前一半:

java 复制代码
public class PaymentInfo {

    @NotBlank
    @BankCard                    // 银行卡号:13-19位 + Luhn 算法
    private String cardNo;

    @NotBlank
    @CVV                         // CVV/CVC 安全码:3或4位数字
    private String cvv;

    @NotBlank
    @Pattern(regexp = "(0[1-9]|1[0-2])/\\d{2}", message = "有效期格式必须为 MM/YY")
    private String expiry;        // 有效期:MM/YY(用标准注解写正则)
}

@BankCard 背后是 Luhn 算法 ------它会自动拦截"手滑输错一位"的卡号。BankCardValidator 的处理流程:去空格/连字符 → 纯数字 → 13-19 位 → Luhn 校验位:

java 复制代码
public boolean isValid(String value, ConstraintValidatorContext context) {
    if (value == null || value.isEmpty()) {
        return true; // 空值处理交给@NotNull等其他注解处理
    }
    // 移除所有空格和连字符
    String cleanValue = value.replaceAll("[\\s-]", "");
    // 检查是否全部为数字
    if (!cleanValue.matches("\\d+")) {
        return false;
    }
    // 检查长度是否符合银行卡号规范(通常为13-19位)
    if (cleanValue.length() < 13 || cleanValue.length() > 19) {
        return false;
    }
    // 使用Luhn算法验证银行卡号
    return isLuhnValid(cleanValue);
}

卡号"格式合法" ≠ "卡可用"。真实支付前还需要 BIN 识别发卡行、调用银行接口验证------Luhn 只是第一道闸。BIN 识别可参考本站文章 22《银行卡BIN码识别》。


六、注解与链式 API:同一规则两种写法

同一个订单场景,注解用于 DTO 声明式校验 ,链式 API 用于 方法内即时校验(适合没有 DTO 的简单接口或预处理)。两种写法等价:

① 注解方式(推荐用于入参 DTO)

java 复制代码
public class PayRequest {

    @TradeOrderNumber
    private String orderNo;

    @BankCard
    private String cardNo;

    @CVV
    private String cvv;
}

// 使用
Set<ConstraintViolation<PayRequest>> violations = validator.validate(req);

② 链式 API(适合没有 DTO 的场景)

java 复制代码
ValidX v = ValidX.init();
v.isTradeOrderNumber(orderNo)
 .isBankCard(cardNo)
 .isCVV(cvv);

if (v.passed()) {
    payService.pay(orderNo, cardNo, cvv);
} else {
    // v.getErrors() 返回所有失败信息
    throw new BusinessException(v.getErrors().get(0));
}

链式 API 的优势是一次调用链完成多字段校验并收集全部错误 ,而不是"第一处失败就 return"。isTradeOrderNumberisBankCardisCVV 的书写顺序就是支付接口的执行顺序,可读性极强。


七、分组验证:草稿 / 提交 / 支付三阶段

电商最常见的需求:同一张订单,不同阶段校验不同字段

  • 草稿阶段:只要求订单号存在,其它都允许空
  • 提交阶段:收货信息必填、支付方式合法
  • 支付阶段:银行卡信息必填

Bean Validation(JSR-380)的 groups 机制正好解决。定义两个分组接口:

java 复制代码
public interface SubmitGroup { }   // 提交时校验
public interface PayGroup { }      // 支付时校验

然后给字段挂上分组:

java 复制代码
public class OrderDTO {

    @TradeOrderNumber(groups = {SubmitGroup.class, PayGroup.class})
    private String orderNo;

    @NotBlank(groups = SubmitGroup.class)
    @ChineseName(groups = SubmitGroup.class)
    private String receiverName;      // 提交时才校验

    @NotBlank(groups = SubmitGroup.class)
    @ChinesePhone(groups = SubmitGroup.class)
    private String receiverPhone;

    @NotBlank(groups = PayGroup.class)
    @BankCard(groups = PayGroup.class)
    private String cardNo;            // 支付时才校验

    @NotBlank(groups = PayGroup.class)
    @CVV(groups = PayGroup.class)
    private String cvv;
}

使用时分组校验:

java 复制代码
// 提交订单:只校验 SubmitGroup
validator.validate(orderDTO, SubmitGroup.class);

// 发起支付:只校验 PayGroup
validator.validate(orderDTO, PayGroup.class);

执行效果

阶段 校验的字段 未声明的字段
提交订单 订单号、收货人、手机号 银行卡、CVV(跳过)
发起支付 订单号、银行卡、CVV 收货人、手机号(跳过)

这就是"从购物车到支付"完整链路中"一条链路、两种校验强度"的标准解法。


八、源码解读:TradeOrderNumberValidator 支持哪些格式

订单号是订单链路最核心的字段,@TradeOrderNumber 到底接受什么?看源码(TradeOrderNumberValidator.java):

java 复制代码
// T开头+18位数字格式
private static final Pattern PREFIX_T_PATTERN = Pattern.compile("^T\\d{18}$");

// 纯18位数字格式
private static final Pattern DIGITS_18_PATTERN = Pattern.compile("^\\d{18}$");

// UUID格式(带连字符)
private static final Pattern UUID_PATTERN =
        Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");

// UUID格式(不带连字符)
private static final Pattern UUID_NO_HYPHEN_PATTERN = Pattern.compile("^[0-9a-fA-F]{32}$");

public boolean isValid(String value, ConstraintValidatorContext context) {
    if (value == null || value.isEmpty()) {
        return true; // 空值处理交给@NotNull等其他注解处理
    }
    // 移除所有空格和连字符
    String cleanValue = value.replaceAll("[\\s-]+", "");
    // 验证是否匹配任一支持的格式
    return PREFIX_T_PATTERN.matcher(cleanValue).matches()
        || DIGITS_18_PATTERN.matcher(cleanValue).matches()
        || UUID_PATTERN.matcher(cleanValue).matches()
        || UUID_NO_HYPHEN_PATTERN.matcher(cleanValue).matches();
}

四个格式覆盖三类订单号生成策略:

生成策略 匹配格式 示例
业务前缀 + 时间戳 T + 18 位数字 T202510171234567890
纯时间戳/流水 18 位纯数字 202510171234567890
UUID 带连字符 550e8400-e29b-41d4-a716-446655440000
UUID 紧凑版 32 位十六进制 550e8400e29b41d4a716446655440000

几个值得注意的实现细节:

  1. 正则全部预编译static final Pattern),高并发下单场景没有重复编译开销
  2. replaceAll("[\\s-]+", "") 先归一化 ------容忍前端传来的 2025-10-17-1234-5678-90 之类带分隔符的写法(去连字符后恰为 18 位数字,命中纯数字格式)
  3. 一个实现瑕疵值得留意 :连字符在归一化时已被删除,因此第 3 个"带连字符 UUID"正则实际不可达------带连字符 UUID 之所以能通过,靠的是删连字符后命中"32 位紧凑 UUID"分支。若要保留该分支,应先匹配原始串、再归一化
  4. null/空串放行 (JSR-380 惯例)------是否必填由 @NotBlank 决定,职责分离
  5. 四选一用 || 短路------前一个匹配成功就不再匹配后面的正则

九、边界与坑

1. 金额绝不能用 Double 比较

0.1 + 0.2 != 0.3。订单金额一律 BigDecimal,构造用字符串(new BigDecimal("19.90") 而不是 new BigDecimal(19.9)),校验用 @Digits 控精度,比较用 compareTo

2. 校验顺序:格式校验先于业务校验

先跑注解校验(格式),再跑服务层业务校验(库存、余额、限购)。把"手机号格式错"和"库存不足"混在一个异常里,前端没法精准提示。

3. 支付接口必须防重

@TradeOrderNumber 只保证格式,不保证唯一。幂等键 + 数据库唯一索引缺一不可,Luhn 和订单号正则都防不了重复提交。

4. CVV 不要落库

CVV 校验通过后立即丢弃,任何日志、数据库、缓存都不许存------PCI-DSS 合规红线。文章里 PaymentInfo 只做校验 DTO,不持久化。

5. 分组校验记得处理"未分组字段"

validator.validate(dto, PayGroup.class) 时,没有声明 groups 的字段默认属于 Default 组,不会被执行 。如果你的约束(如 @Email)没指定 groups,支付阶段不会校验它------按需给每个约束显式分组,别留默认。

6. 链式 API 的语义

isTradeOrderNumber(null) 会放行(null 合法),所以链式调用前若订单号必填,要自己先判空或用 @NotBlank 语义的检查。ValidX 每个链式方法都遵循"空值放行、由必填注解兜底"的 JSR-380 惯例。

7. 卡号归一化

@BankCard 容忍空格和连字符("5425-2334-3010-9903" 合法),但存库前必须统一格式(去分隔符),否则同一张卡在库里可能有三份记录。


总结

阶段 关键字段 ValidX 能力 标准注解兜底
购物车 数量、单价 ---(金额非格式校验) @Min/@Max/@Digits
收货信息 姓名/手机/邮编/邮箱 @ChineseName/@ChinesePhone/@ChineseZipCode/@Email @NotBlank
订单信息 订单号/支付方式/状态 @TradeOrderNumber/@In/@Enum @NotNull
支付信息 卡号/CVV/有效期 @BankCard(Luhn)/@CVV @Pattern(MM/YY)

从购物车到支付的完整验证链路,本质是四层职责分离

  1. 格式层:ValidX 注解(正则/Luhn,声明式,零散代码清零)
  2. 数值层 :标准 Bean Validation(@Min/@Digits,金额与数量)
  3. 业务层:服务代码(库存、余额、防重、汇总)
  4. 阶段层groups 分组(草稿/提交/支付,一套 DTO 两种强度)

ValidX 的意义在于把最容易写错、最容易遗漏的格式层全部标准化------手机号正则、银行卡 Luhn、订单号格式、身份证校验码......这些都是订单系统的"地基",地基稳了,上面三层才谈得上可靠。


项目地址

相关推荐
吴声子夜歌1 小时前
Guava——并发(一)
java·guava
摇滚侠1 小时前
《SpringBoot 3:入门与应用实战》第 13 章 整合 MyBatis MyBatis 概述 阅读笔记 37
spring boot·笔记·mybatis
天空属于哈夫克31 小时前
企业微信API:企业微信如何实现自动化运营?
java·自动化·企业微信
lhldsg1 小时前
相册印刷实战指南:从设计规范到系统落地全流程解析
java·小程序·架构·设计规范
草青工作室1 小时前
java-不携带Token也能“打“自己的接口:一个绕开网关鉴权、用 URL 直接调度 SpringMVC 方法的轻量级定时任务方案
java·开发语言
霸道流氓气质2 小时前
Spring AI提示词模板与动态变量替换
java·python·spring
Object_SC2 小时前
java 中的泛型
java
摇滚侠2 小时前
《SpringBoot 3:入门与应用实战》第 13 章 整合 MyBatis 整合 MyBatis 阅读笔记 38
spring boot·笔记·mybatis
s_w.h2 小时前
【 linux 】线程互斥与同步
java·linux·服务器·开发语言