☰
ValidX 订单数据验证:从购物车到支付的完整验证链路
2026/10/13 20:49:11 网站建设 项目流程

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

📋 目录

  • 引言
  • 一、订单数据验证全景:四段式链路
  • 二、购物车阶段:商品项验证
  • 三、收货信息验证
  • 四、订单信息验证
  • 五、支付信息验证
  • 六、注解与链式 API:同一规则两种写法
  • 七、分组验证:草稿 / 提交 / 支付三阶段
  • 八、源码解读:TradeOrderNumberValidator 支持哪些格式
  • 九、边界与坑
  • 总结
  • 项目地址

引言

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

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

手写每一处的校验,四个环节加起来是几十段重复的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

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

  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、订单号格式、身份证校验码……这些都是订单系统的"地基",地基稳了,上面三层才谈得上可靠。


项目地址

  • GitHub:https://github.com/vipxieliang/ValidX
  • Gitee:https://gitee.com/vipxieliang/ValidX
  • Maven Central:https://central.sonatype.com/artifact/io.github.vipxieliang/validx

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询