红魔咖啡馆

头发越掉越多,头发越掉越少

0%

【BudgetMatch】Payment RPC回写mall订单状态

payment-rpc 的 markPaid 已经预留回写订单状态 TODO

# 任务

实现

需要实现的目标:支付宝确认收款后,不仅要把payment-rpc的支付流水修改成功,还需要同步把mall-rpc的订单从待支付改为已支付

调用链

目前有两条入口汇总到markPaid

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
用户请求 /pay/query
  → App 校验订单归属
  → payment-rpc QueryPayment
  → 查询支付宝
  → markPaid
  → mall-rpc ConfirmPayment
  → 订单变为已支付

异步通知路径
支付宝 POST /api/pay/notify/alipay
  → App 解析表单
  → payment-rpc HandleNotify
  → 验签和业务校验
  → markPaid
  → mall-rpc ConfirmPayment
  → 订单变为已支付
  → 返回 success 给支付宝

两条路径复用同一个markPaid,可以避免主动查询和异步通知各写一套逻辑,产生行为不一致

支付宝异步通知:

主路径,用户扫码支付后,支付宝主动请求POST /api/pay/notify/alipay,代码位于alipay_notify_logic.go,这个接口不需要用户JWT,因为请求方是支付宝

主动查询支付宝:

兜底路径,异步通知可能因为公网、网络或服务异常没有到达,因此用户还可以调用GET /api/mall/orders/:id/pay/query App调用payment-rpc的QueryPayment,payment-rpc再主动请求支付宝查询交易状态,代码位于query_payment_logic.go

主动查询路径

App收到查询请求后,先从认证context中获得用户身份,再读取Mall订单,不能只相信客户端传入的order_id,还需要保证订单属于当前登录用户

然后App调用QueryPayment,收到请求后再次检查流水归属,若不一致则返回NotFound避免跨用户查询时泄露支付确实存在的事实

支付流水还是待支付:payment-rpc请求支付宝查询,若支付宝返回已支付,则主动调用markPaid标记为已支付

支付流水已成功:代码不会再次请求支付宝,但是仍然调用markPaid,目的是为了重试上一次失败的Mall回写

异步通知路径

App网关只负责接收支付宝表单,不持有支付宝密钥,处理入口会:

  • 限制请求体大小最大为1MB
  • 使用ParseForm解析支付宝表单
  • 转换为map存取的键值对
  • 调用HandleNotify
  • 根据处理结果返回纯文本的success和failure

若返回success,支付宝认为通知已经消费成功,否则支付宝会按照协议继续重试

Payment-rpc通知校验

HandleNotify会先调用DecodeNotify完成RSA2验签,然后进行对应的业务校验,具体可以看业务校验部分的文档

金额转为整数后比较

全部校验通过后,才会进入markPaid

markPaid

这是整条回写链路的核心,主要完成两件事:

  • 幂等更新Payment支付流水
  • 幂等回写Mall订单

首先代码复制一份传入的record,作为候选状态,异步通知也会写入,只有数据库更新成功后,才会把内存中的record替换为成功数据

接着更新Payment流水条件,查询条件为WHERE id = ? AND status = StatusPending,查询到以后更新status为1(支付成功)

这个条件更新解决了并发问题,假设异步通知和主动查询同时到达,只有一个请求能把status从0改为1,另一个请求的RowsAffected为0,可以用这个来判断是否重复操作,它包含两种情况:

  • 另一条并发请求已经把流水标记成功
  • 流水已经关闭,不能再支付

所以代码会重新读取最新流水FindOne,如果最新状态已经是成功,说明是重复通知或并发确认,直接采用数据库最新数据回写mall

若不是成功,返回conflict

为什么重复请求仍然调用mall?

因为payment和mall是两个数据库,payment更新成功的情况下,mall-rpc可能会调用失败

如果看到payment成功就直接返回,那么mall可能永远停留在待支付状态

因此现在的语义是:

  • 支付流水成功:不重复修改流水
  • Mall回写:允许重复调用

Payment获取调用mall的权限

后期改为了独立身份认证,不用再伪造普通用户JWT

payment-rpc在回写前调用serviceauth.GenerateToken,生成一个有效期一分钟的服务 JWT,并放入 gRPC metadata

包含的字段在对应文档里可以看到,使用密钥和普通用户JWT分开

这样普通用户即使有合法用户JWT,也不能直接调用ConfirmPayment

Mall限制Payment回写

Mall的认证拦截器将ConfirmPayment注册为服务专用接口:

1
2
3
4
"/mall.OrderService/ConfirmPayment": {
  Caller:   "payment-rpc",
  Audience: "mall-rpc",
}

拦截器对这个接口不会走普通用户JWT校验,而是走特定的校验,逻辑内部还会再次检查是否是payment调用

ConfirmPayment保证幂等

mall不因为请求来自payment就完全信任,还会重新查询订单并校验存在性、一致性

事务和行锁:

Mall在数据库事务中使用SELECT ... FOR UPDATE行级锁(符合条件的每一行上一个排他锁)锁住订单,这样两个并发的confirm不会同时修改同一订单

相同支付重复确认:

如果订单已经保存了相同的out_trade_notrade_no,则认为是同一笔支付的重复确认,执行sameConfirmPayment,Mall不会再次更新订单,也不会再次创建事件,直接返回success和wasAlreadyConfirmed

不同支付标识冲突:

如果订单已经有了交易号,又传入了不同的交易号,则是潜在的数据冲突,直接返回conflict

这样可以防止另一笔支付覆盖原有订单的支付凭据

原子更新订单:

订单更新条件:WHERE id = ? AND user_id = ? AND status = pending

注意mall订单状态和payment流水状态是两个枚举,payment为1时标识支付成功,mall为2时表示订单已支付

写入outbox事件

订单更新为已支付后,后续还可能需要其他业务,因此不能事务提交后直接发消息队列,否则可能出现订单更新成功但消息队列发送失败,所以mall使用了outbox,更新订单为已支付后插入paid outbox事件,任何一步失败整个事务回滚

事件的稳定去重键:order:<order_id>:paid,outbox表的dedup_key还有唯一索引,因此不会为同一个订单重复创建多个paid事件,消费者侧还有inbox去重,相同事件重复投递就会跳过

事务提交后,后台dispatcher再把outbox事件发到消息队列,不影响原子性

重复查询不扣库存

因为库存是在创建订单事务中扣减,而不是支付确认时扣减,支付回写只做更新订单状态、写支付时间、交易号和创建outbox事件,因此重复查询时库存不会变化,和当前幂等设计一致

跨服务事务失败时

两个数据库不能放到同一个本地事务,因此系统采用一致性:

支付宝成功→payment流水成功→mall回写成功

当出现payment流水成功,mall-rpc不可用时:

  1. Payment 流水保持成功,不回滚支付宝事实。
  2. 记录明确的 Mall 回写失败日志。
  3. 当前请求返回失败。
  4. 支付宝异步通知收到 failure 后可能重试。
  5. 用户再次主动查询时,发现流水已成功,继续重试 Mall 回写。
  6. Mall 对重复请求进行幂等处理。

# 测试