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_no和trade_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不可用时:
- Payment 流水保持成功,不回滚支付宝事实。
- 记录明确的 Mall 回写失败日志。
- 当前请求返回失败。
- 支付宝异步通知收到 failure 后可能重试。
- 用户再次主动查询时,发现流水已成功,继续重试 Mall 回写。
- Mall 对重复请求进行幂等处理。
# 测试