总览
整个支付模块可以分为三部分:
- 创建支付:系统生成支付流水和二维码
- 确认到账:支付宝通知或主动查询确认交易成功
- 确认任务完成:订单状态变为已支付,并可靠发布订单已支付事件
链路:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
用户
│
│ 1. 点击支付
▼
App API 网关
│ 读取登录用户、查询订单、获取服务端金额
▼
payment-rpc
│ 创建本地支付流水
│ 调支付宝预下单
▼
支付宝
│ 返回二维码
▼
用户扫码付款
│
├──────── 异步通知 ────────┐
│ ▼
│ App API 回调入口
│ │
│ ▼
│ payment-rpc 验签
│
└── 用户主动查询 ──> payment-rpc 查询支付宝
│
▼
本地支付流水变成成功
│
▼
mall-rpc ConfirmPayment
│
事务更新订单为已支付
同事务写入 Outbox
│
▼
Outbox Dispatcher
│
▼
RocketMQ详细流程
订单创建
用户下单后,mall-rpc中会存在一笔订单,金额单位为分,避免精度问题
这里要区分两种状态:
- 订单状态:待支付、已支付、已取消
- 支付流水状态:待支付、支付成功、交易关闭
它们不是一个概念,比如支付宝付款成功,但因为服务调用失败,商城订单可能暂时还是待支付状态
支付创建
订单校验
当用户调用支付API:POST /api/mall/orders/:id/pay时,网关会依次完成如下操作:
- 从登录上下文读取用户ID,这里不能让用户自己传userid,否则用户可以修改参数,尝试支付其他人的订单
- 从mall-rpc查询订单,传入订单id和当前用户id,并检查订单对应的用户id是否和当前登录用户匹配,若不匹配统一返回订单不存在
- 检查余额:系统会检查实付金额:原始金额-优惠金额,且实付金额必须大于0,注意支付金额必须来自服务端订单,而不能相信前端传入的金额
- 检查订单状态:只有待支付订单(PENDING)才能发起支付
校验完成后,网关向payment-rpc发送CreatePaymentReq请求体,记录订单id,userid和支付金额等
支付流水
req传入payment-rpc后,payment-rpc首先检查请求合法性:
- orderid不为空
- userid不为空
- amount大于00
- 支付宝配置必须存在
- 订单是否已存在支付流水
若已经支付成功:
直接返回原商户订单号和成功状态,不再调用支付宝,对应的场景是订单重复点击支付,则查询到success流水后直接返回success流水
若已经存在待支付流水:
复用原来的支付流水和订单号,实现幂等操作
若没有流水或流水关闭:
创建新的支付流水:
1
2
3
4
5
6
7
id = pay-xxx
out_trade_no = 32位UUID
order_id = order-001
user_id = user-001
amount = 480000
channel = alipay
status = pending其中:
order_id是商城业务订单号out_trade_no是系统提交给支付宝的商户订单号trade_no是支付宝支付成功后生成的平台交易号
支付宝相关
预下单
支付宝相关位于infra/alipay/alipay.go
payment-rpc调用支付宝当面付预下单PreCreate(outTradeNo, subject, amountFen,)
将以分为单位的金额转换为元后,提交商户订单号、订单标题、支付金额和异步通知地址,支付宝返回qrcode是内容字符串,payment-rpc将二维码保存到支付流水,返回给前端,前端需要将其渲染为二维码
为什么支付成功后,二维码保存失败还返回二维码:
支付宝此时已经成功创建了支付单,如果因为本地数据库短暂失败就返回失败,用户重试后可能再次发起预下单
链路:支付宝预下单成功→本地二维码保存失败→记录错误日志→把二维码返回给用户
这是对外部成功,内部失败的处理
完成扣款
用户扫描二维码,资金交易通过支付宝完成,这一步之后前端页面显示的支付成功不能作为服务端依据,因为客户端结果可以伪造,服务端只能通过:支付宝异步通知和服务端主动查询支付宝确认支付结果
发送异步通知
支付宝会向公网地址发送表单请求:
1
2
POST /api/pay/notify/alipay
Content-Type: application/x-www-form-urlencoded处理逻辑位于alipay_notify_logic.go
为什么回调接口不需要jwt鉴权:
请求来自支付宝服务器,而不是用户,自然不会携带jwt
所以回调路由必须允许匿名访问,真实性依靠的是支付宝签名验证
网关的作用:
- 限制请求体大小1mb
- 解析支付宝表单参数
- 将原始数据透传给payment-rpc
网关不持有支付宝密钥,只存在支付服务中
RSA2验签与校验
验签入口在handle_notify_logic.go,支付宝使用支付宝私钥对通知内容签名,而系统使用支付宝公钥验证签名,若有人修改了金额等信息,签名就无法通过,它可以保证通知来自持有支付宝私钥的一方,且通知内容在传输过程中没有被修改
但是验签只能证明这份数据是真的由支付宝签发,不代表这份交易一定属于当前应用和当前订单
因此验签结束后,我们需要继续校验notify中的appid和sellerid与系统配置的是否相同,并继续查询本地支付流水,使用out_trade_no查询payment表,并校验金额和交易号冲突
最后校验交易状态,只有success和finish才能确认交易成功
确认订单支付
支付流水确认成功后,需要调用mall-rpc的ConfirmPayment,传入对应的request,订单侧实现位于confirm_payment_logic.go
注意:这里即使支付流水已经是成功态,也不能简单返回,因为商城订单可能还没有同步成功,即支付流水已经成功≠业务链路已经完成
我们还需要检查或重试ConfirmPayment,每次收到重复通知时继续调用幂等的ConfirmPayment
事务和行锁
ConfirmPayment会开启数据库事务,使用类似SELECT ... FOR UPDATE的行锁锁住当前订单行
当异步通知和主动查询同时到达,若没有锁,A和B可能同时看到订单是待支付,然后都更新订单都生成支付成功事件
若上了行锁:
- A先锁住订单并完成事务
- B等待A提交
- B再次读取时发现订单已经支付
- B通过幂等路径返回
事务中检查:订单是否存在、用户是否一致、支付金额是否一致、是否已经由相同交易确认、是否存在不同交易号冲突、当前订单是否为待支付
订单更新和outbox
订单确认事务中同时完成修改订单状态、写入out_trade_no、trade_no、pay_time、插入outbox事件
这些要么同时成功,要么同时回滚,保证原子性
当数据库订单更新成功,但服务进程突然宕机,还没来得及发送消息队列的情况下,如果不是原子性,下游将会永远收不到支付成功事件,反过来同理,下游认为订单已经支付,但数据库中还是待支付,出现幽灵信息
outbox通过设定的发送器dispatcher发送事件:
- 从outbox表领取待发送事件
- 将事件标记为处理中并设置锁定时间,同步发送到rocketmq
- 失败后计算下一次重试时间
- 超过最大次数后转为dead状态
重试间隔使用指数退避,即第一次失败在1秒后,第二次失败在2秒后,第三次失败在4秒后…最大不超过5分钟
注意:outbox不能保证消息绝对只发送一次,可能发生mq接收成功,服务器还没来得及把outbox标记为sent就宕机了,所以outbox提供的是至少一次投递,下游消费者必须根据event_id、idempotency_key和dedup_key幂等消费
主动查询
用户还可以通过调用GET /api/mall/orders/:id/pay/query主动查询,它会查询本地支付流水:若尚未成功,主动查询支付宝,若支付宝显示成功,同步本地支付状态,并调用mall-rpc确认订单
它解决了以下问题:
- 本地开发没有公网地址,收不到支付宝回调
- 支付宝回调暂时丢失,或到达时服务不可用
- payment-rpc执行成功,但是mall-rpc回写失败
- 用户希望立即刷新支付结果
因此异步通知和主动查询必须进入同一个确认方法markPaid
常见异常
用户重复点击支付 复用待支付流水和商户订单号
支付宝重复通知 原子更新支付流水,重复请求幂等返回
通知签名非法 不查询、不更新任何业务数据
通知金额不一致 拒绝确认并记录安全日志
payment成功、mall调用失败 保留支付成功,返回失败并等待重试/补偿
mall成功但RPC响应超时 重试ConfirmPayment,mall幂等返回已确认
订单事务成功、MQ不可用 Outbox保留事件并后台重试
MQ成功但Outbox未标记 可能重复发送,消费者进行幂等去重
订单已取消但支付宝成功 进入异常支付,后续退款或人工处理
多次重试仍无法投递 转入dead状态并告警、人工处理