多来买:支付入口与本地支付流水实战
多来买:支付入口与本地支付流水实战
核心问题:未支付订单怎样生成支付宝页面,以及为什么必须提前保存本地支付流水
事实来源:PayController、PayServiceImpl、PaymentInfo、AlipayHelper 与 OrderApiClient 当前源码
证据口径:代码已确认;未访问支付宝、未执行沙箱或真实支付
本篇停在“支付页面已经生成、本地流水处于 UNPAID”这一时刻。支付宝服务器回调后的验签、防重、状态推进和一致性边界,见同专题的《支付宝回调验签幂等与订单通知实战》。
1. 项目背景与本章目标
1.1 要解决的业务问题
支付链路至少要回答六个问题:
- 用户只能为哪些订单获取支付页面?
- 第三方平台使用哪个业务编号识别本地订单?
- 回调怎样证明来自支付宝,而不是客户端伪造?
- 回调中的应用、状态、订单号和金额怎样与本地事实核对?
- 支付宝重复通知时,怎样避免重复推进订单和库存?
- 支付流水已经更新、订单远程调用却失败时,系统处于什么状态?
1.2 本章学习目标
读完本文后,应能沿源码讲清:
UNPAID订单怎样生成支付宝页面;payment_info为什么要在回调前落库;- 回调为什么必须先验签、再校验业务参数、最后做幂等;
notify_id怎样形成 Redis 防重键;- 本地支付流水怎样从
UNPAID变成PAID; - 支付服务怎样通过 Feign 推进订单和仓储链路;
- 当前实现在哪些地方仍存在重复写、精度和跨服务一致性风险。
1.3 本文不展开的内容
- 支付宝账号、密钥、网关地址和 Nacos 配置;
- 项目的环境搭建、启动、联调或冒烟步骤;
- 仓储按仓拆单和库存锁定的完整实现;
- 当前代码没有实现的退款、对账和补单系统。
2. 模块职责与技术选型
2.1 模块职责
| 模块或组件 | 本链路中的职责 | 不负责什么 |
|---|---|---|
pay-service | 生成支付页面、保存支付流水、接收并校验回调 | 不直接保存订单明细 |
order-service | 提供订单支付信息、接收支付成功通知、继续触发仓储 | 不负责支付宝验签 |
ware-service | 在订单支付后处理库存工作单与库存锁定 | 不判断支付宝回调真伪 |
MySQL payment_info | 保存本地支付事实和回调内容 | 不能单独阻止并发重复回调 |
| Redis / Redisson | 保存 notify_id 防重标记 | 不替代数据库事务或跨服务补偿 |
| 支付宝 SDK | 生成页面、验签、查询和关闭第三方交易 | 不保证本地订单一定更新成功 |
| OpenFeign | 支付服务与订单服务之间的同步调用 | 不提供分布式事务 |
2.2 技术在当前链路中的用途
| 技术 | 当前代码中的具体用途 | 为什么需要 |
|---|---|---|
| Spring MVC | 暴露支付页面和异步回调接口 | 绑定路径参数与表单参数 |
| MyBatis-Plus | 查询、插入和更新 payment_info | 保存本地支付流水 |
| BigDecimal | 保存订单金额和回调金额 | 金额需要十进制精度 |
| 支付宝 SDK | pageExecute 生成页面、rsaCheckV1 验签 | 遵循支付宝协议 |
Redisson RBucket | trySet 创建回调防重标记 | 将“第一次处理”变成原子判断 |
| OpenFeign | 查询订单、通知支付成功 | 跨服务同步协作 |
| Fastjson | 把回调参数序列化到本地流水 | 保留问题追踪信息 |
3. 端到端链路
3.1 生成支付页面
代码已确认的调用顺序如下:
这里先查订单,再创建支付流水。payment_info 因此成为本地系统核验回调的依据:只有能在本地找到相同 outTradeNo 和支付渠道的流水,回调才会继续处理。
4. 接口契约与业务分支
4.1 三个支付入口
| 接口 | 方法 | 输入 | 当前用途 |
|---|---|---|---|
/pay/auth | GET | orderId 查询参数 | 返回订单支付展示信息 |
/pay/alipay/submit/{orderId} | GET | 订单 ID | 生成支付宝页面 HTML |
/pay/notify/url | POST | 支付宝表单参数 Map | 校验并处理异步通知 |
仓库内只能确认服务内部路径。客户端是否还要经过 Gateway 前缀,取决于 Nacos 中的路由配置,本轮没有运行验证。
4.2 回调分支真值表
| 条件 | 当前处理 | 对支付宝的响应 |
|---|---|---|
| RSA 验签失败 | 立即拒绝 | failure |
trade_status 不是 TRADE_SUCCESS | 不更新本地数据 | failure |
app_id 与本地配置不一致 | 不更新本地数据 | failure |
| 找不到本地支付流水 | 无法证明是本系统订单 | failure |
| 回调金额与本地金额不一致 | 拒绝推进订单 | failure |
| Redis 防重标记已存在 | 认为此前已接收 | success |
| 第一次处理且业务成功 | 保留防重标记 | success |
| 第一次处理但业务失败 | 删除防重标记以便重试 | failure |
4.3 关键回调参数
| 参数 | 当前用途 |
|---|---|
out_trade_no | 定位本地支付流水 |
total_amount | 与本地金额比较 |
app_id | 防止其他支付宝应用的通知混入 |
trade_status | 只接受交易成功 |
notify_id | 构造 Redis 防重键 |
trade_no | 保存支付宝侧交易流水号 |
sign 等签名字段 | 由支付宝 SDK 验签 |
当前代码没有逐个校验参数是否为空;参数缺失时可能进入构造 BigDecimal、拼接 Redis key 或查询数据库等后续路径。这是静态可见的输入健壮性边界。
5. 本地支付数据模型
5.1 PaymentInfo 保存什么
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/model/PaymentInfo.java
@Data@TableName("payment_info")public class PaymentInfo extends BaseEntity {
@TableField("out_trade_no") private String outTradeNo;
@TableField("order_id") private Long orderId;
@TableField("user_id") private Long userId;
@TableField("payment_type") private String paymentType;
@TableField("trade_no") private String tradeNo;
@TableField("total_amount") private BigDecimal totalAmount;
@TableField("payment_status") private String paymentStatus;
@TableField("callback_time") private Date callbackTime;
@TableField("callback_content") private String callbackContent;}字段可以分成三组:
- 回调前已知:
outTradeNo、orderId、userId、paymentType、totalAmount; - 回调后补充:
tradeNo、callbackTime、callbackContent; - 状态字段:
paymentStatus从UNPAID推进到PAID。
outTradeNo 是本地订单对外交易号,tradeNo 是支付宝侧流水号,二者不能混用。
5.2 支付状态
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/constant/PaymentStatus.java
public enum PaymentStatus { UNPAID("支付中"), PAID("已支付"), PAY_FAIL("支付失败"), CLOSED("已关闭");}当前主回调链实际使用 UNPAID 和 PAID。PayServiceImpl#updatePaymentStatus 仍是空方法,因此不能说本地支付流水已经完整覆盖关闭和失败状态流转。
5.3 数据状态流
图中只画当前主链实际发生的流转;CLOSED 和 PAY_FAIL 是设计状态,不代表当前代码已经完成对应闭环。
6. 第一步:查询订单并生成支付入口
6.1 Controller 保持轻量
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/controller/PayController.java
关键方法:getOrderInfo、submitOrder
@GetMapping("/pay/auth")public Result getOrderInfo(@RequestParam("orderId") Long orderId) { OrderInfoDTO orderInfoDTO = orderApiClient.getOrderInfoDTO(orderId); return Result.ok(orderInfoDTO);}
@GetMapping("/pay/alipay/submit/{orderId}")public String submitOrder(@PathVariable Long orderId) { String payType = "Alipay"; return payService.createPay(orderId, payType);}Controller 只完成三件事:
- 绑定订单 ID;
- 查询展示信息或委托 Service 创建支付;
- 把订单 DTO 或页面 HTML 返回给调用方。
当前支付渠道在 Controller 中固定为 Alipay。虽然 Service 已通过工厂抽象 PayHelper,但接口还没有让客户端或订单选择其他渠道。
6.2 Feign 查询订单
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/client/OrderApiClient.java
@FeignClient(value = "service-order")public interface OrderApiClient {
@GetMapping("/api/order/inner/getOrderInfo/{orderId}") OrderInfoDTO getOrderInfoDTO(@PathVariable("orderId") Long orderId);
@PostMapping("/api/order/inner/success/{orderId}") Result successPay(@PathVariable("orderId") Long orderId);}支付服务不直接读订单数据库,而是通过订单服务的内部 API 获取订单事实。这样保持了服务边界,但也意味着创建支付页面依赖订单服务实时可用。
7. 第二步:只为未支付订单创建页面
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/service/impl/PayServiceImpl.java
关键方法:createPay
@Overridepublic String createPay(Long orderId, String payTypeName) {
OrderInfoDTO orderInfoDTO = orderApiClient.getOrderInfoDTO(orderId); String orderStatus = orderInfoDTO.getOrderStatus();
if (!OrderStatus.UNPAID.name().equals(orderStatus)) { return "该订单支付状态异常!"; }
savePaymentInfo(orderInfoDTO, PaymentType.ALIPAY.name());
PayHelper payhelper = payHelpFactory.getPayhelper(payTypeName); return payhelper.getPage(orderInfoDTO);}关键判断是 OrderStatus.UNPAID:
- 已支付订单不能再次生成支付页面;
- 已关闭订单不能重新发起原交易;
- 其他异常状态也不会继续创建本地流水。
当前方法没有显式处理订单不存在、Feign 异常或工厂找不到渠道的情况;这些异常会沿调用栈返回,而不是转换成统一支付错误。
8. 第三步:保存本地支付流水
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/service/impl/PayServiceImpl.java
关键方法:savePaymentInfo
PaymentInfo paymentInfo = paymentInfoConverter.contvertOrderInfoDTO2PaymentInfo(orderInfo);paymentInfo.setPaymentType(paymentTypeName);paymentInfo.setPaymentStatus(PaymentStatus.UNPAID.name());
LambdaQueryWrapper<PaymentInfo> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(PaymentInfo::getOutTradeNo, paymentInfo.getOutTradeNo()) .eq(PaymentInfo::getOrderId, paymentInfo.getOrderId());
PaymentInfo judgeExist = paymentInfoMapper.selectOne(queryWrapper);
if (judgeExist != null) { paymentInfoMapper.updateById(paymentInfo);}
paymentInfoMapper.insert(paymentInfo);设计意图是“存在则更新,不存在才插入”,但当前控制流有两个明确事实:
if内没有return或else;- 无论
judgeExist是否为空,最后都会执行insert。
此外,updateById(paymentInfo) 依赖实体主键,而这里的 paymentInfo 来自订单 DTO 转换,代码没有把 judgeExist.getId() 写回。能否更新到原记录取决于转换结果和基类字段,不能假设一定成功。
因此,当前实现不能表述为“支付流水创建天然幂等”。更准确的说法是:它进行了存在性查询,但控制流仍可能重复插入;数据库是否存在唯一约束,本轮也没有建表脚本可确认。
9. 第四步:构建支付宝支付页面
代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/alipay/AlipayHelper.java
关键方法:getPage
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();AlipayTradePagePayModel model = new AlipayTradePagePayModel();
model.setOutTradeNo(orderInfoDTO.getOutTradeNo());model.setTotalAmount(orderInfoDTO.getTotalAmount().toString());model.setSubject(orderInfoDTO.getTradeBody());model.setProductCode("FAST_INSTANT_TRADE_PAY");model.setTimeExpire( DateUtil.getYyyymmddhhmmss(orderInfoDTO.getExpireTime()));
request.setBizModel(model);AlipayTradePagePayResponse response = alipayClient.pageExecute(request, "POST");
return response.getBody();四个核心参数分别解决:
outTradeNo:支付宝回调时把通知映射回本地订单;totalAmount:第三方交易金额;subject:支付页面展示标题;timeExpire:把订单过期时间交给第三方平台。
原方法还设置同步跳转和异步通知地址。文档不回显当前源码里的完整值,只保留其职责:
- 同步跳转用于用户体验,不能作为到账事实;
- 异步通知必须是支付宝服务器可以访问的地址;
- 当前异步地址仍是占位形式,属于代码已确认的配置缺口。
10. 短复习点
- 只有订单状态为
UNPAID才进入页面创建主链。 - 支付服务通过 Feign 获取订单事实,不直接读取订单数据库。
payment_info在回调前记录本地交易号、订单、用户、渠道、金额和UNPAID状态。- 当前
savePaymentInfo即使查到记录仍会执行尾部insert,不能称为创建幂等。 - 同步跳转只改善用户体验,到账事实必须以服务端异步通知或主动查询为准。
11. 关键源码导航
| 阅读顺序 | 文件 | 关键方法或类型 | 作用 |
|---|---|---|---|
| 1 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/controller/PayController.java | getOrderInfo、submitOrder | 支付入口 |
| 2 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/service/impl/PayServiceImpl.java | createPay | 校验订单并组织创建流程 |
| 3 | 同上 | savePaymentInfo | 保存本地支付流水 |
| 4 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/model/PaymentInfo.java | 实体字段 | 本地支付事实 |
| 5 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/alipay/AlipayHelper.java | getPage | 构造支付宝页面请求 |
| 6 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/client/OrderApiClient.java | getOrderInfoDTO | 查询订单事实 |
12. 本章总结
支付服务只为未支付订单创建支付宝页面,并先保存本地支付流水作为后续回调核验依据;当前重复插入和跨服务失败仍需要唯一约束与幂等状态机兜底。
本篇不记录支付密钥、应用 ID、完整回调地址或其他敏感配置。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!