订单数据验证:从购物车到支付的完整验证链路
📋 目录
- 引言
- 一、订单数据验证全景:四段式链路
- 二、购物车阶段:商品项验证
- 三、收货信息验证
- 四、订单信息验证
- 五、支付信息验证
- [六、注解与链式 API:同一规则两种写法](#六、注解与链式 API:同一规则两种写法 "#%E5%85%AD%E6%B3%A8%E8%A7%A3%E4%B8%8E%E9%93%BE%E5%BC%8F-api%E5%90%8C%E4%B8%80%E8%A7%84%E5%88%99%E4%B8%A4%E7%A7%8D%E5%86%99%E6%B3%95")
- [七、分组验证:草稿 / 提交 / 支付三阶段](#七、分组验证:草稿 / 提交 / 支付三阶段 "#%E4%B8%83%E5%88%86%E7%BB%84%E9%AA%8C%E8%AF%81%E8%8D%89%E7%A8%BF--%E6%8F%90%E4%BA%A4--%E6%94%AF%E4%BB%98%E4%B8%89%E9%98%B6%E6%AE%B5")
- [八、源码解读:TradeOrderNumberValidator 支持哪些格式](#八、源码解读:TradeOrderNumberValidator 支持哪些格式 "#%E5%85%AB%E6%BA%90%E7%A0%81%E8%A7%A3%E8%AF%BBtradeordernumbervalidator-%E6%94%AF%E6%8C%81%E5%93%AA%E4%BA%9B%E6%A0%BC%E5%BC%8F")
- 九、边界与坑
- 总结
- 项目地址
引言
一个订单从产生到支付,要经过四个环节:购物车结算 → 填写收货信息 → 生成订单 → 发起支付。每个环节都有不同的数据要校验:
| 环节 | 典型数据 | 校验重点 |
|---|---|---|
| 购物车 | 商品 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 注解,两者各司其职。
quantity 和 price 的下限判断必须放在服务层做汇总校验(购物车为空、总价超限),因为那是业务规则,不是字段格式:
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"。isTradeOrderNumber → isBankCard → isCVV 的书写顺序就是支付接口的执行顺序,可读性极强。
七、分组验证:草稿 / 提交 / 支付三阶段
电商最常见的需求:同一张订单,不同阶段校验不同字段。
- 草稿阶段:只要求订单号存在,其它都允许空
- 提交阶段:收货信息必填、支付方式合法
- 支付阶段:银行卡信息必填
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 |
几个值得注意的实现细节:
- 正则全部预编译 (
static final Pattern),高并发下单场景没有重复编译开销 replaceAll("[\\s-]+", "")先归一化 ------容忍前端传来的2025-10-17-1234-5678-90之类带分隔符的写法(去连字符后恰为 18 位数字,命中纯数字格式)- 一个实现瑕疵值得留意 :连字符在归一化时已被删除,因此第 3 个"带连字符 UUID"正则实际不可达------带连字符 UUID 之所以能通过,靠的是删连字符后命中"32 位紧凑 UUID"分支。若要保留该分支,应先匹配原始串、再归一化
- null/空串放行 (JSR-380 惯例)------是否必填由
@NotBlank决定,职责分离 - 四选一用
||短路------前一个匹配成功就不再匹配后面的正则
九、边界与坑
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) |
从购物车到支付的完整验证链路,本质是四层职责分离:
- 格式层:ValidX 注解(正则/Luhn,声明式,零散代码清零)
- 数值层 :标准 Bean Validation(
@Min/@Digits,金额与数量) - 业务层:服务代码(库存、余额、防重、汇总)
- 阶段层 :
groups分组(草稿/提交/支付,一套 DTO 两种强度)
ValidX 的意义在于把最容易写错、最容易遗漏的格式层全部标准化------手机号正则、银行卡 Luhn、订单号格式、身份证校验码......这些都是订单系统的"地基",地基稳了,上面三层才谈得上可靠。
项目地址
- GitHub :github.com/vipxieliang...
- Gitee :gitee.com/vipxieliang...
- Maven Central :central.sonatype.com/artifact/io...