多来买:支付宝回调验签幂等与订单通知实战
多来买:支付宝回调验签幂等与订单通知实战
核心问题:支付宝异步通知怎样通过验签和业务校验,如何防重并推进订单
事实来源:PayController、PayServiceImpl、OrderApiClient、OrderServiceImpl 与 Redis 常量当前源码
证据口径:代码已确认;未接收真实通知、未验证支付宝协议行为或跨服务运行结果
支付页面只是交易入口。真正影响本地支付状态的是支付宝服务器的异步通知。当前代码按“验签 → 交易和应用校验 → 本地流水与金额校验 → Redis 防重 → 更新支付流水 → Feign 通知订单”的顺序处理。
1. 异步回调端到端时序
“返回 success”是支付平台是否继续重试的重要协议结果。当前代码只在所有本地业务返回成功后响应 success;失败会删除 Redis 标记并响应 failure,允许后续通知重试。
2. 第五步:先验证支付宝签名
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/controller/PayController.java
关键方法:notifyUrl
boolean signVerified = AlipaySignature.rsaCheckV1( paramsMap, csmallAlipayConfig.getAlipayPublicKey(), CsmallAlipayConfig.charset, CsmallAlipayConfig.sign_type);
if (!signVerified) { log.info("验签失败"); return "failure";}验签证明“参数集合与支付宝签名匹配”,主要防止:
- 客户端自己构造成功回调;
- 通知在传输中被篡改;
- 攻击者修改金额、订单号或状态后继续使用旧签名。
验签必须放在业务写操作之前。若先更新订单再验签,伪造请求已经造成状态变化,后续拒绝也无法撤销。
验签并不等于业务完全可信。即使签名正确,仍要确认回调是否属于当前应用、当前本地订单以及正确金额。
3. 第六步:校验状态、应用、订单和金额
3.1 读取业务参数
String outTradeNo = paramsMap.get("out_trade_no");String totalAmount = paramsMap.get("total_amount");BigDecimal callbackAmount = new BigDecimal(totalAmount);String appId = paramsMap.get("app_id");String tradeStatus = paramsMap.get("trade_status");3.2 只接受支付成功并匹配本地应用
if (!"TRADE_SUCCESS".equals(tradeStatus)) { return "failure";}
if (!csmallAlipayConfig.getAppId().equals(appId)) { return "failure";}TRADE_SUCCESS 表示交易完成。当前代码对其他状态统一返回 failure,没有为“等待付款”“交易关闭”等状态建立独立处理分支。
3.3 用本地流水校验订单和金额
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/service/impl/PayServiceImpl.java
关键方法:queryPaymentInfoByOutTradeNoAndPaymentType
LambdaQueryWrapper<PaymentInfo> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(PaymentInfo::getOutTradeNo, outTradeNo) .eq(PaymentInfo::getPaymentType, payTypeName);
PaymentInfo paymentInfo = paymentInfoMapper.selectOne(queryWrapper);if (paymentInfo != null) { return paymentInfoConverter.convertPaymentInfoToDTO(paymentInfo);}return null;Controller 拿到本地流水后继续比较金额:
PaymentInfoDTO paymentInfoDTO = payService.queryPaymentInfoByOutTradeNoAndPaymentType( outTradeNo, PaymentType.ALIPAY.name());
if (paymentInfoDTO == null) { return "failure";}
BigDecimal localTotalAmount = paymentInfoDTO.getTotalAmount();if (localTotalAmount.doubleValue() != callbackAmount.doubleValue()) { return "failure";}“先查本地流水再比较”这个方向正确,但比较实现有精度风险。double 是二进制浮点数,金额应使用:
// 改进示例,不是当前实现if (localTotalAmount.compareTo(callbackAmount) != 0) { return "failure";}BigDecimal.compareTo 比较数值大小,不受 1.0 与 1.00 小数位不同影响,也不会先丢失为二进制浮点精度。
4. 第七步:使用 Redis 做回调防重
4.1 当前 Redis 数据形态
代码路径:duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.java
key = pay:callback:notifyid:{notify_id}value = out_trade_nowrite = RBucket.trySet(value)TTL = 当前调用没有设置4.2 trySet 为什么能判断第一次处理
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/controller/PayController.java
关键方法:notifyUrl
String notifyId = paramsMap.get("notify_id");String key = RedisConst.PAY_CALL_BACK_VERFY_PREFIX + notifyId;RBucket<Object> bucket = redissonClient.getBucket(key);
boolean firstHandle = bucket.trySet(outTradeNo);
if (!firstHandle) { return "success";}trySet 的语义近似 Redis 的“key 不存在才写入”:
- 第一次回调写入成功,返回
true,进入业务处理; - 重复回调发现 key 已存在,返回
false,直接响应success; - 判断和写入由 Redis 原子完成,避免两个并发请求都先查到不存在。
4.3 业务失败为什么删除标记
Boolean flag = payService.successPay( outTradeNo, PaymentType.ALIPAY.name(), paramsMap);
if (!flag) { bucket.delete(); return "failure";}
return "success";如果支付流水或订单推进失败,保留标记会让后续通知直接返回 success,业务再也没有自动重试入口。因此当前代码删除标记,并通过 failure 告诉支付宝稍后重试。
4.4 当前防重边界
代码中虽然定义了回调标记过期常量,但 trySet 没有传 TTL,当前标记会长期存在。由此有两面性:
- 好处:相同
notify_id的重复通知长期不会再次处理; - 风险:Redis 会积累标记;业务人工重放或协议异常时也缺少自然过期边界。
此外,防重键只使用 notify_id。数据库中没有看到“状态条件更新”或唯一回调记录配合,因此 Redis 丢失、被清理或使用了不同通知 ID 时,数据库层仍可能再次进入状态推进。
5. 第八步:更新支付流水并通知订单服务
5.1 回填支付成功信息
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/service/impl/PayServiceImpl.java
关键方法:successPay
PaymentInfoDTO paymentInfoDTO = queryPaymentInfoByOutTradeNoAndPaymentType(outTradeNo, name);
PaymentInfo paymentInfo = new PaymentInfo();paymentInfo.setId(paymentInfoDTO.getId());paymentInfo.setPaymentStatus(PaymentStatus.PAID.name());paymentInfo.setTradeNo(paramsMap.get("trade_no"));paymentInfo.setCallbackTime(new Date());paymentInfo.setCallbackContent(JSON.toJSONString(paramsMap));
int affectedRows = paymentInfoMapper.updateById(paymentInfo);if (affectedRows < 1) { throw new RuntimeException("修改支付表的状态失败");}这里使用“只构造 ID 和待修改字段”的实体调用 updateById,目的是只更新本次回调产生的字段。成功后,本地流水拥有:
- 支付状态
PAID; - 支付宝交易号;
- 回调时间;
- 回调参数 JSON。
当前更新条件只有主键,没有附加 payment_status = UNPAID。如果重复进入该方法,数据库不会用状态条件阻止再次执行后续订单调用。
5.2 Feign 推进订单
Result result = orderApiClient.successPay(paymentInfoDTO.getOrderId());
if (!ResultCodeEnum.SUCCESS.getCode().equals(result.getCode())) { throw new RuntimeException("远程调用订单失败");}
return true;支付服务只关心订单接口是否返回统一成功码。订单内部接口随后执行:
代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/inner/OrderApiController.java
@PostMapping("/api/order/inner/success/{orderId}")public Result successPay(@PathVariable("orderId") Long orderId) { orderService.successPay(orderId); return Result.ok();}订单 Service 的核心动作是:
代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java
关键方法:successPay
OrderInfo orderInfo = new OrderInfo();orderInfo.setId(orderId);orderInfo.setOrderStatus(OrderStatus.PAID.name());orderInfo.setUpdateTime(new Date());orderInfoMapper.updateById(orderInfo);
wareApiClient.decreaseStock(orderId);因此,支付回调不是在支付服务直接扣库存,而是:
支付回调 -> payment_info = PAID -> Feign 通知 order-service -> order_info = PAID -> Feign 调用 ware-service -> 创建库存任务并锁定库存仓储核心代码增加的是 stock_locked,不是本文可以宣称的“完成真实出库扣减”。
6. 事务、并发、幂等与一致性边界
6.1 当前事务边界
PayServiceImpl#successPay 没有 @Transactional,而且即使添加本地事务,也只能覆盖支付数据库,不能自动覆盖订单和仓储服务。
当前至少有三个独立提交点:
- 支付服务更新
payment_info; - 订单服务更新
order_info; - 仓储服务创建任务并锁定库存。
6.2 典型中间状态
| 失败位置 | 已完成事实 | 可能结果 |
|---|---|---|
| 支付流水更新失败 | Redis 标记已创建 | 删除标记,等待重试 |
| 支付流水成功、Feign 调订单失败 | payment_info = PAID | 删除标记,重试时再次更新并调用订单 |
| 订单已更新、响应在网络中丢失 | 订单可能已触发仓储 | 支付端认为失败并重试,存在重复库存链风险 |
| 订单更新成功、仓储失败 | order_info = PAID | 订单留在中间状态,依赖后续恢复 |
| Redis 不可用 | 验签和业务校验可能已完成 | 当前请求无法建立防重标记 |
6.3 当前幂等保护了什么
- 相同 Redis key 的并发
trySet只有一个成功; - 相同
notify_id的后续通知直接返回success; - 业务返回失败时删除标记,保留平台重试机会。
6.4 当前没有完全保护什么
- 本地支付流水创建仍可能重复插入;
- 支付状态更新没有状态条件;
- 订单
successPay不是显式幂等状态机; - 仓储调用可能被重复触发;
- Redis 标记和 MySQL 状态没有原子提交;
- 没有可靠事件、补偿任务或支付对账事实。
因此面试中适合说“实现了回调入口的 Redis 防重”,不适合说“整个支付、订单、库存链路已经严格端到端幂等”。
7. 关键场景静态推演
以下根据当前代码进行静态推演,不是运行或联调结果。
7.1 正常首次回调
POST /pay/notify/urlContent-Type: application/x-www-form-urlencoded
out_trade_no=<本地交易号>&total_amount=<本地金额>&app_id=<当前应用>&trade_status=TRADE_SUCCESS¬ify_id=<通知唯一标识>&trade_no=<第三方交易号>&sign=<支付宝签名>推演结果:
- 验签通过;
- 业务状态、应用、流水和金额匹配;
- Redis
trySet成功; - 支付流水更新为
PAID; - 订单服务被通知;
- 全链返回成功后响应
success。
7.2 相同通知重复到达
第二次携带同一 notify_id:
- 前置验签和业务校验仍会执行;
trySet返回false;- 不再次调用
successPay; - 直接响应
success。
7.3 金额不一致
即使签名正确,只要本地金额与回调金额不一致,当前代码也会响应 failure,不会创建 Redis 防重标记,也不会更新支付或订单状态。
7.4 订单服务暂时不可用
支付流水可能已经更新为 PAID,Feign 调用随后失败。successPay 捕获异常并返回 false,Controller 删除 Redis 标记并响应 failure。支付宝重试时会重新进入业务链,但本地流水没有自动回滚到 UNPAID。
7.5 回调参数缺失
若 total_amount 缺失,new BigDecimal(totalAmount) 可能直接抛出异常;若 notify_id 缺失,Redis key 会拼接空值语义。当前 Controller 没有统一参数校验和异常到 failure 的显式转换。
8. 当前实现的亮点与真实取舍
8.1 亮点
- 不依赖前端跳转确认到账,而是接收平台异步通知;
- 验签放在所有业务写入之前;
- 继续校验应用、状态、本地订单和金额;
- 使用 Redis 原子写入处理重复回调;
- 业务失败会删除标记,保留第三方重试机会;
- 本地支付流水保留第三方交易号、回调时间和回调内容;
- 通过订单内部 API 保持支付与订单数据边界。
8.2 真实取舍
- 同步 Feign 实现直观,但扩大了回调请求的故障面;
- Redis 防重实现简单,但和数据库状态不是原子事务;
- 保存完整回调便于排障,但需要考虑敏感字段、字段长度和日志治理;
- 工厂抽象了支付渠道,但 Controller 当前仍固定使用支付宝。
9. 已确认限制与候选风险
| 结论 | 证据等级 | 直接依据 | 影响 |
|---|---|---|---|
已有支付流水仍会执行 insert | 代码已确认 | savePaymentInfo 的无条件尾部插入 | 可能重复流水或触发唯一约束 |
金额转为 double 比较 | 代码已确认 | notifyUrl | 存在浮点精度风险 |
| 防重标记没有 TTL | 代码已确认 | trySet(outTradeNo) 未传过期时间 | Redis 标记长期累积 |
| 本地支付更新与订单 Feign 非原子 | 代码已确认 | successPay 调用顺序 | 可能出现中间状态 |
| 订单成功接口会继续触发仓储 | 代码已确认 | OrderServiceImpl#successPay | 重复调用影响会继续放大 |
| 异步通知地址仍是占位形式 | 代码已确认 | AlipayHelper#getPage | 当前回调可达性不能确认 |
| 关闭支付状态方法为空 | 代码已确认 | updatePaymentStatus | 本地关闭状态闭环不完整 |
| 支付表唯一约束未知 | 未验证 | 仓库没有建表脚本 | 无法确认重复插入的数据库结果 |
平台是否总复用同一 notify_id | 未验证 | 本轮未访问支付宝协议与沙箱 | 防重键长期语义待确认 |
10. 如果重新设计会怎样演进
以下是改进建议,不是当前代码已经具备的能力。
10.1 支付流水创建
- 为
out_trade_no + payment_type建立唯一约束; - 使用“查到即返回”或数据库原子 upsert;
- 已存在时明确带主键更新支付渠道和有效字段。
10.2 回调状态更新
可使用条件更新表达状态机:
-- 改进示例,不是当前 MapperUPDATE payment_infoSET payment_status = 'PAID', trade_no = ?, callback_time = ?, callback_content = ?WHERE id = ? AND payment_status = 'UNPAID';受影响行数为 0 时再查询当前状态:
- 已经
PAID:作为幂等成功; - 已经
CLOSED:进入“关单与支付竞态”处理; - 记录不存在:拒绝并告警。
10.3 跨服务一致性
一种演进方向是:
支付宝回调 -> 本地事务更新 payment_info -> 同一事务写 payment_success_event/outbox -> 后台可靠投递 -> order-service 幂等消费 -> ware-service 使用订单号或工作单唯一键防重这样回调可以在本地事实落库后尽快响应,订单和库存通过可重试事件推进;仍需要订单、仓储各自的幂等约束和对账任务。
10.4 安全与可运维性
- 对回调参数做显式非空和格式校验;
- 金额使用
BigDecimal.compareTo; - 配置回调地址,不在源码中保留占位;
- 防重标记设置符合协议的 TTL,并以数据库状态兜底;
- 建立支付对账、失败事件扫描和人工补单入口;
- 日志脱敏,不完整打印支付页面或签名参数。
11. 关键源码导航
| 阅读顺序 | 文件 | 关键类或方法 | 作用 |
|---|---|---|---|
| 1 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/controller/PayController.java | submitOrder | 支付页面入口 |
| 2 | 同上 | notifyUrl | 验签、业务校验和 Redis 防重 |
| 3 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/service/impl/PayServiceImpl.java | createPay | 校验订单并创建支付 |
| 4 | 同上 | savePaymentInfo | 保存本地支付流水 |
| 5 | 同上 | successPay | 更新流水并通知订单 |
| 6 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/model/PaymentInfo.java | PaymentInfo | 支付表字段 |
| 7 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/alipay/AlipayHelper.java | getPage | 构造支付宝页面请求 |
| 8 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/client/OrderApiClient.java | OrderApiClient | 支付到订单的 Feign 契约 |
| 9 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/inner/OrderApiController.java | successPay | 订单内部通知入口 |
| 10 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java | successPay | 订单状态与仓储调用 |
| 11 | duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.java | 支付回调常量 | 防重 key 前缀与未使用 TTL |
12. 四条短复习点
- 验签只能证明通知参数的来源与完整性,仍需校验应用、交易状态、本地交易号和金额。
RBucket.trySet原子保护同一notify_id的回调入口;业务失败删除标记以保留重试机会。- Redis 防重不等于端到端幂等,支付数据库、订单和仓储仍有独立提交点。
- 当前主要边界包括金额转
double比较、防重标记无 TTL、状态更新无条件以及同步 Feign 中间状态。
13. 本章总结
当前实现已经具备支付回调的正确骨架:服务端通知、RSA 验签、业务事实核验、入口防重和失败重试。但准确表达只能是“实现了相同通知 ID 的 Redis 防重,并能推进订单”,不能扩大成“支付、订单、仓储已经严格原子或端到端幂等”。
演进时应以数据库状态机和唯一约束兜底,再通过可靠事件或 outbox 推进订单,并为每个下游消费者建立业务幂等键与对账补偿。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!