【BudgetMatch】Payment模块

  • ~4.55K 字
  1. 1. 总览
  2. 2. 详细流程
    1. 2.1. 订单创建
    2. 2.2. 支付创建
      1. 2.2.1. 订单校验
      2. 2.2.2. 支付流水
    3. 2.3. 支付宝相关
      1. 2.3.1. 预下单
      2. 2.3.2. 完成扣款
      3. 2.3.3. 发送异步通知
    4. 2.4. RSA2验签与校验
    5. 2.5. 确认订单支付
    6. 2.6. 事务和行锁
    7. 2.7. 订单更新和outbox
    8. 2.8. 主动查询
    9. 2.9. 常见异常

总览

整个支付模块可以分为三部分:

  • 创建支付:系统生成支付流水和二维码
  • 确认到账:支付宝通知或主动查询确认交易成功
  • 确认任务完成:订单状态变为已支付,并可靠发布订单已支付事件

链路:

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状态并告警、人工处理

赞助喵
非常感谢您的喜欢!
赞助喵