订单数据验证:从购物车到支付的完整验证链路
📋 目录
- 引言
- 一、订单数据验证全景:四段式链路
- 二、购物车阶段:商品项验证
- 三、收货信息验证
- 四、订单信息验证
- 五、支付信息验证
- 六、注解与链式 API:同一规则两种写法
- 七、分组验证:草稿 / 提交 / 支付三阶段
- 八、源码解读:TradeOrderNumberValidator 支持哪些格式
- 九、边界与坑
- 总结
- 项目地址
引言
一个订单从产生到支付,要经过四个环节:购物车结算 → 填写收货信息 → 生成订单 → 发起支付。每个环节都有不同的数据要校验:
| 环节 | 典型数据 | 校验重点 |
|---|---|---|
| 购物车 | 商品 ID、数量、单价 | 数量范围、金额精度 |
| 收货信息 | 姓名、手机号、地址、邮编 | 格式合法性(中文姓名/手机号/邮编) |
| 订单信息 | 订单号、支付方式、订单状态 | 订单号格式、枚举合法性 |
| 支付信息 | 银行卡号、CVV | Luhn 算法、安全码格式 |
手写每一处的校验,四个环节加起来是几十段重复的if-else+ 正则。而 ValidX 恰好把每一类都封装成了现成能力——本文用一条完整链路演示:如何用注解 + 链式 API 把"购物车到支付"的校验全部串起来。
一、订单数据验证全景:四段式链路
先看整条链路的校验点分布:
购物车结算 ──► 填写收货信息 ──► 生成订单 ──► 发起支付 ① 商品项 ② 收货信息 ③ 订单信息 ④ 支付信息 · 数量 1-99 · @ChineseName · 订单号 @TradeOrderNumber · 卡号 @BankCard(Luhn) · 单价 @Digits(8,2) · @ChinesePhone · 支付方式 @In/@Enum · 安全码 @CVV · 金额上限见服务层 · @ChineseZipCode · 状态 @Enum(target=OrderStatus) · 有效期 MM/YY @Pattern · @Email四个环节,每一类数据都能在 ValidX 找到对应注解。下面逐段实现。
二、购物车阶段:商品项验证
购物车结算时,前端传来的是一组商品项。每个商品项要验证数量和金额:
publicclassCartItem{@NotBlankprivateStringskuId;// 商品 SKU 必填@Min(value=1,message="商品数量至少为1")@Max(value=99,message="单件商品最多购买99件")privateintquantity;// 数量:1-99@NotNull@Digits(integer=8,fraction=2)// 单价:最多8位整数 + 2位小数privateBigDecimalprice;// 单价}注意:金额(BigDecimal)用标准 Bean Validation(JSR-380)的@Digits/@Min/@Max校验,而不是正则——金额是数值语义,正则只适合字符串格式。ValidX 的注解聚焦"格式类"校验(手机号、卡号、身份证……),数值范围类交给标准 Bean Validation 注解,两者各司其职。
quantity和price的下限判断必须放在服务层做汇总校验(购物车为空、总价超限),因为那是业务规则,不是字段格式:
if(cartItems.isEmpty()){thrownewBusinessException("购物车不能为空");}BigDecimaltotal=cartItems.stream().map(i->i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity()))).reduce(BigDecimal.ZERO,BigDecimal::add);if(total.compareTo(newBigDecimal("50000"))>0){thrownewBusinessException("单笔订单金额不能超过5万元");}三、收货信息验证
结算后的下一步是收货信息。这一段的字段几乎全是"中国业务格式",正好是 ValidX 的主场:
publicclassShippingInfo{@NotBlank@ChineseName// 中文姓名:2-50个汉字,支持少数民族姓名间隔号"·"privateStringreceiverName;@NotBlank@ChinesePhone// 中国大陆手机号:11位,1[3-9]开头privateStringreceiverPhone;@Email// 电子邮箱privateStringemail;@NotBlank@ChineseZipCode// 中国邮政编码:6位数字privateStringzipCode;@NotBlankprivateStringaddress;// 详细地址(自由文本,长度校验即可)}五个字段,四类格式注解,全部声明式搞定。每个注解都对应一个独立的ConstraintValidator,例如@ChinesePhone底层是预编译的手机号格式正则(11 位、1[3-9]开头,不枚举具体号段,避免新放号段被误杀)——把最容易写错的手机号正则,从业务代码里彻底移除。
四、订单信息验证
订单从创建到支付,请求里反复出现的两类字段是订单号和枚举类字段(支付渠道、订单状态)。注意一个前提:订单号由后端在创建订单时生成(生成策略见第八节),创建请求本身不含它;需要校验格式的,是客户端回传订单号的操作(查询、支付、取消、确认收货):
publicclassOrderSubmitDTO{// 提交/流转已生成订单:orderNo 为创建接口返回后回传@NotBlank@TradeOrderNumber// 订单号:T+18位数字 / 18位数字 / UUID(含32位紧凑版)privateStringorderNo;@NotNull@In(value={"WECHAT","ALIPAY","BANK_CARD","BALANCE"},message="不支持的支付方式")privateStringpayChannel;// 支付方式:白名单校验@NotBlank@Enum(target=OrderStatus.class,field="code")privateStringstatus;// 订单状态:必须是枚举 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 负责前一半:
publicclassPaymentInfo{@NotBlank@BankCard// 银行卡号:13-19位 + Luhn 算法privateStringcardNo;@NotBlank@CVV// CVV/CVC 安全码:3或4位数字privateStringcvv;@NotBlank@Pattern(regexp="(0[1-9]|1[0-2])/\\d{2}",message="有效期格式必须为 MM/YY")privateStringexpiry;// 有效期:MM/YY(用标准注解写正则)}@BankCard背后是 Luhn 算法——它会自动拦截"手滑输错一位"的卡号。BankCardValidator的处理流程:去空格/连字符 → 纯数字 → 13-19 位 → Luhn 校验位:
publicbooleanisValid(Stringvalue,ConstraintValidatorContextcontext){if(value==null||value.isEmpty()){returntrue;// 空值处理交给@NotNull等其他注解处理}// 移除所有空格和连字符StringcleanValue=value.replaceAll("[\\s-]","");// 检查是否全部为数字if(!cleanValue.matches("\\d+")){returnfalse;}// 检查长度是否符合银行卡号规范(通常为13-19位)if(cleanValue.length()<13||cleanValue.length()>19){returnfalse;}// 使用Luhn算法验证银行卡号returnisLuhnValid(cleanValue);}卡号"格式合法" ≠ “卡可用”。真实支付前还需要 BIN 识别发卡行、调用银行接口验证——Luhn 只是第一道闸。BIN 识别可参考本站文章 22《银行卡BIN码识别》。
六、注解与链式 API:同一规则两种写法
同一个订单场景,注解用于DTO 声明式校验,链式 API 用于方法内即时校验(适合没有 DTO 的简单接口或预处理)。两种写法等价:
① 注解方式(推荐用于入参 DTO):
publicclassPayRequest{@TradeOrderNumberprivateStringorderNo;@BankCardprivateStringcardNo;@CVVprivateStringcvv;}// 使用Set<ConstraintViolation<PayRequest>>violations=validator.validate(req);② 链式 API(适合没有 DTO 的场景):
ValidXv=ValidX.init();v.isTradeOrderNumber(orderNo).isBankCard(cardNo).isCVV(cvv);if(v.passed()){payService.pay(orderNo,cardNo,cvv);}else{// v.getErrors() 返回所有失败信息thrownewBusinessException(v.getErrors().get(0));}链式 API 的优势是一次调用链完成多字段校验并收集全部错误,而不是"第一处失败就 return"。isTradeOrderNumber→isBankCard→isCVV的书写顺序就是支付接口的执行顺序,可读性极强。
七、分组验证:草稿 / 提交 / 支付三阶段
电商最常见的需求:同一张订单,不同阶段校验不同字段。
- 草稿阶段:只要求订单号存在,其它都允许空
- 提交阶段:收货信息必填、支付方式合法
- 支付阶段:银行卡信息必填
Bean Validation(JSR-380)的groups机制正好解决。定义两个分组接口:
publicinterfaceSubmitGroup{}// 提交时校验publicinterfacePayGroup{}// 支付时校验然后给字段挂上分组:
publicclassOrderDTO{@TradeOrderNumber(groups={SubmitGroup.class,PayGroup.class})privateStringorderNo;@NotBlank(groups=SubmitGroup.class)@ChineseName(groups=SubmitGroup.class)privateStringreceiverName;// 提交时才校验@NotBlank(groups=SubmitGroup.class)@ChinesePhone(groups=SubmitGroup.class)privateStringreceiverPhone;@NotBlank(groups=PayGroup.class)@BankCard(groups=PayGroup.class)privateStringcardNo;// 支付时才校验@NotBlank(groups=PayGroup.class)@CVV(groups=PayGroup.class)privateStringcvv;}使用时分组校验:
// 提交订单:只校验 SubmitGroupvalidator.validate(orderDTO,SubmitGroup.class);// 发起支付:只校验 PayGroupvalidator.validate(orderDTO,PayGroup.class);执行效果:
| 阶段 | 校验的字段 | 未声明的字段 |
|---|---|---|
| 提交订单 | 订单号、收货人、手机号 | 银行卡、CVV(跳过) |
| 发起支付 | 订单号、银行卡、CVV | 收货人、手机号(跳过) |
这就是"从购物车到支付"完整链路中"一条链路、两种校验强度"的标准解法。
八、源码解读:TradeOrderNumberValidator 支持哪些格式
订单号是订单链路最核心的字段,@TradeOrderNumber到底接受什么?看源码(TradeOrderNumberValidator.java):
// T开头+18位数字格式privatestaticfinalPatternPREFIX_T_PATTERN=Pattern.compile("^T\\d{18}$");// 纯18位数字格式privatestaticfinalPatternDIGITS_18_PATTERN=Pattern.compile("^\\d{18}$");// UUID格式(带连字符)privatestaticfinalPatternUUID_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格式(不带连字符)privatestaticfinalPatternUUID_NO_HYPHEN_PATTERN=Pattern.compile("^[0-9a-fA-F]{32}$");publicbooleanisValid(Stringvalue,ConstraintValidatorContextcontext){if(value==null||value.isEmpty()){returntrue;// 空值处理交给@NotNull等其他注解处理}// 移除所有空格和连字符StringcleanValue=value.replaceAll("[\\s-]+","");// 验证是否匹配任一支持的格式returnPREFIX_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:https://github.com/vipxieliang/ValidX
- Gitee:https://gitee.com/vipxieliang/ValidX
- Maven Central:https://central.sonatype.com/artifact/io.github.vipxieliang/validx