管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

多来买:支付入口与本地支付流水实战

2923 字
15 分钟
多来买:支付入口与本地支付流水实战

多来买:支付入口与本地支付流水实战#

核心问题:未支付订单怎样生成支付宝页面,以及为什么必须提前保存本地支付流水
事实来源:PayController、PayServiceImpl、PaymentInfo、AlipayHelper 与 OrderApiClient 当前源码
证据口径:代码已确认;未访问支付宝、未执行沙箱或真实支付

本篇停在“支付页面已经生成、本地流水处于 UNPAID”这一时刻。支付宝服务器回调后的验签、防重、状态推进和一致性边界,见同专题的《支付宝回调验签幂等与订单通知实战》。

1. 项目背景与本章目标#

1.1 要解决的业务问题#

支付链路至少要回答六个问题:

  1. 用户只能为哪些订单获取支付页面?
  2. 第三方平台使用哪个业务编号识别本地订单?
  3. 回调怎样证明来自支付宝,而不是客户端伪造?
  4. 回调中的应用、状态、订单号和金额怎样与本地事实核对?
  5. 支付宝重复通知时,怎样避免重复推进订单和库存?
  6. 支付流水已经更新、订单远程调用却失败时,系统处于什么状态?

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保存订单金额和回调金额金额需要十进制精度
支付宝 SDKpageExecute 生成页面、rsaCheckV1 验签遵循支付宝协议
Redisson RBuckettrySet 创建回调防重标记将“第一次处理”变成原子判断
OpenFeign查询订单、通知支付成功跨服务同步协作
Fastjson把回调参数序列化到本地流水保留问题追踪信息

3. 端到端链路#

3.1 生成支付页面#

代码已确认的调用顺序如下:

sequenceDiagram participant Browser as "浏览器" participant PC as "PayController" participant PS as "PayServiceImpl" participant OC as "OrderApiClient" participant Order as "order-service" participant DB as "MySQL payment_info" participant Ali as "支付宝" Browser->>PC: GET /pay/alipay/submit/{orderId} PC->>PS: createPay(orderId, "Alipay") PS->>OC: getOrderInfoDTO(orderId) OC->>Order: 查询订单及明细 Order-->>PS: OrderInfoDTO alt 订单不是 UNPAID PS-->>Browser: 订单支付状态异常 else 订单是 UNPAID PS->>DB: 保存本地 UNPAID 支付流水 PS->>Ali: pageExecute(支付模型) Ali-->>Browser: 支付页面 HTML end

这里先查订单,再创建支付流水。payment_info 因此成为本地系统核验回调的依据:只有能在本地找到相同 outTradeNo 和支付渠道的流水,回调才会继续处理。

4. 接口契约与业务分支#

4.1 三个支付入口#

接口方法输入当前用途
/pay/authGETorderId 查询参数返回订单支付展示信息
/pay/alipay/submit/{orderId}GET订单 ID生成支付宝页面 HTML
/pay/notify/urlPOST支付宝表单参数 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;
}

字段可以分成三组:

  1. 回调前已知:outTradeNoorderIduserIdpaymentTypetotalAmount
  2. 回调后补充:tradeNocallbackTimecallbackContent
  3. 状态字段:paymentStatusUNPAID 推进到 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("已关闭");
}

当前主回调链实际使用 UNPAIDPAIDPayServiceImpl#updatePaymentStatus 仍是空方法,因此不能说本地支付流水已经完整覆盖关闭和失败状态流转。

5.3 数据状态流#

stateDiagram-v2 [*] --> UNPAID: 生成支付页面前保存流水 UNPAID --> PAID: 回调校验通过并更新成功 note right of UNPAID CLOSED 与 PAY_FAIL 枚举存在; 当前主链没有有效写入。 end note

图中只画当前主链实际发生的流转;CLOSEDPAY_FAIL 是设计状态,不代表当前代码已经完成对应闭环。

6. 第一步:查询订单并生成支付入口#

6.1 Controller 保持轻量#

代码路径:duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/controller/PayController.java
关键方法:getOrderInfosubmitOrder

@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

@Override
public 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);

设计意图是“存在则更新,不存在才插入”,但当前控制流有两个明确事实:

  1. if 内没有 returnelse
  2. 无论 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. 短复习点#

  1. 只有订单状态为 UNPAID 才进入页面创建主链。
  2. 支付服务通过 Feign 获取订单事实,不直接读取订单数据库。
  3. payment_info 在回调前记录本地交易号、订单、用户、渠道、金额和 UNPAID 状态。
  4. 当前 savePaymentInfo 即使查到记录仍会执行尾部 insert,不能称为创建幂等。
  5. 同步跳转只改善用户体验,到账事实必须以服务端异步通知或主动查询为准。

11. 关键源码导航#

阅读顺序文件关键方法或类型作用
1duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/controller/PayController.javagetOrderInfosubmitOrder支付入口
2duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/service/impl/PayServiceImpl.javacreatePay校验订单并组织创建流程
3同上savePaymentInfo保存本地支付流水
4duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/model/PaymentInfo.java实体字段本地支付事实
5duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/alipay/AlipayHelper.javagetPage构造支付宝页面请求
6duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/client/OrderApiClient.javagetOrderInfoDTO查询订单事实

12. 本章总结#

支付服务只为未支付订单创建支付宝页面,并先保存本地支付流水作为后续回调核验依据;当前重复插入和跨服务失败仍需要唯一约束与幂等状态机兜底。

本篇不记录支付密钥、应用 ID、完整回调地址或其他敏感配置。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

多来买:支付入口与本地支付流水实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-payment-entry-local-ledger/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录