App 支付中如何验证服务端返回的“支付订单信息”?
结论
服务端返回的“支付订单信息”,不能由 App 单独判断是否支付成功。App 只需要检查订单信息是否为空、格式是否符合预期,然后原样传给支付 SDK。可信的支付结果应由商户服务端通过支付平台的异步通知确认,或调用平台的订单查询接口核实。
App 与业务服务端之间必须使用 HTTPS,并结合登录态、订单归属校验和短期有效机制,避免订单信息被窃取、替换或重复使用。
为什么不能只在 App 端验证
App 运行在用户设备上,代码、网络请求和本地状态都可能被篡改。因此,以下信息不能直接作为发货或开通权益的依据:
- App 收到服务端返回的订单信息后,便认为订单有效;
- 支付 SDK 返回“支付成功”后,立即发货;
- 客户端自行修改订单状态;
- 只验证支付回调中的签名,不核对订单金额、商户身份和订单号。
支付 SDK 返回的同步结果主要用于更新界面。即使结果显示成功,也可能遇到网络异常、结果伪造、订单金额不一致或异步通知尚未到达等情况。
推荐处理流程
1. App 向业务服务端创建订单
App 只提交商品编号、购买数量等业务参数,不直接提交最终支付金额。服务端根据商品配置重新计算金额,并生成唯一的商户订单号。
POST /api/orders
{
"productId": "vip_monthly",
"quantity": 1
}
创建订单后,服务端将初始状态保存为 PENDING,再调用支付平台接口,或使用商户私钥生成支付订单信息。
返回结构化数据通常比一段无法识别的字符串更便于处理:
{
"orderId": "ORD202609140001",
"payOrderInfo": "支付平台要求的订单字符串",
"expiresAt": 1789372800
}
2. App 对响应做基础校验
客户端可以检查:
- HTTP 请求是否成功;
orderId和payOrderInfo是否为空;- 返回的订单是否属于当前登录用户;
- 订单是否已经过期;
- 当前订单状态是否允许发起支付。
这些检查只能用来发现接口异常和明显错误,不能作为最终的安全验证。
if (response.orderId.isBlank() || response.payOrderInfo.isBlank()) {
showError("支付订单信息无效")
return
}
if (response.expiresAt <= System.currentTimeMillis() / 1000) {
showError("订单已过期,请重新下单")
return
}
paymentSdk.pay(response.payOrderInfo)
如果支付平台要求传入完整的签名订单字符串,应把服务端返回的内容原样交给 SDK。不要在客户端重新拼接,也不要先解码再编码或替换其中的特殊字符。
3. 服务端验证支付结果
服务端收到支付平台的异步通知后,至少需要检查以下内容:
- 使用支付平台提供的验签方法验证通知签名;
- 核对商户号、应用 ID 或收款方身份;
- 确认商户订单号存在;
- 核对支付金额、币种与本地订单是否一致;
- 确认支付状态属于可认定为成功的状态;
- 确认平台交易号没有被其他订单使用;
- 通过事务或条件更新处理重复通知,避免重复发货。
下面是一段与具体支付平台无关的 Java 示意代码。verifySignature 必须替换为对应平台官方 SDK 提供的验签方法:
@PostMapping("/payment/notify")
public String handlePaymentNotify(@RequestParam Map<String, String> params) {
boolean signatureValid = paymentVerifier.verifySignature(params);
if (!signatureValid) {
return "failure";
}
String orderNo = params.get("out_trade_no");
String tradeNo = params.get("trade_no");
String amountText = params.get("total_amount");
String tradeStatus = params.get("trade_status");
Order order = orderService.findByOrderNo(orderNo);
if (order == null) {
return "failure";
}
BigDecimal paidAmount;
try {
paidAmount = new BigDecimal(amountText);
} catch (RuntimeException e) {
return "failure";
}
if (order.getAmount().compareTo(paidAmount) != 0) {
return "failure";
}
if (!paymentVerifier.matchesMerchant(params)) {
return "failure";
}
if (!paymentVerifier.isSuccessfulStatus(tradeStatus)) {
return "failure";
}
orderService.markPaidIdempotently(orderNo, tradeNo);
return "success";
}
更新数据库时应带上状态条件,例如只允许订单从 PENDING 更新为 PAID:
UPDATE payment_order
SET status = 'PAID',
platform_trade_no = :tradeNo,
paid_at = CURRENT_TIMESTAMP
WHERE order_no = :orderNo
AND status = 'PENDING';
如果受影响的行数为 0,应继续判断该订单是否已经处理,不能再次发货。
4. App 查询业务服务端的订单状态
支付 SDK 返回结果后,App 可以向自己的业务服务端查询订单状态:
GET /api/orders/ORD202609140001/status
{
"orderId": "ORD202609140001",
"status": "PAID"
}
异步通知可能会有延迟,此时可以短时间轮询,但要限制轮询次数和间隔。如果订单长时间仍处于处理中,应提示用户稍后查看,不能直接判定支付失败并再次扣款。
是否需要在 App 端验签
这取决于支付平台的协议:
- 如果平台 SDK 明确支持同步结果验签,可以进行验签,以便改善客户端的状态展示;
- 如果“支付订单信息”由商户私钥签名,App 通常不应持有商户私钥;
- 即使客户端验签成功,也不能替代服务端对异步通知的验签和订单字段核对。
私钥只能保存在服务端或受控的密钥系统中,不能写入 App、配置文件、日志或前端代码。
注意事项
- 不要信任客户端提交的价格,金额必须由服务端计算。
- 不要只根据 SDK 的同步返回值发货或开通权益。
- 验签成功后,仍需核对订单号、金额、币种、商户身份和支付状态。
- 回调接口必须支持幂等处理,因为支付平台可能重复发送通知。
- 金额应使用
BigDecimal或最小货币单位的整数表示,避免使用float、double。 - 支付订单信息和回调参数可能包含敏感数据,不要将其完整写入普通日志。
- 如果没有收到异步通知,服务端可以调用支付平台的订单查询接口补查。
- 不同支付平台使用的签名字段、编码规则和成功状态并不相同,应以当前接入平台及对应 SDK 版本的接口说明为准。
备注:内容仅供参考。
