Go-kratos 框架商城微服务实战之支付服务 (十五)
上一篇文章把订单从购物车带到了待支付。
这个状态看起来很平静。订单已经写进数据库,库存也已经预占成功,用户只需要点一下支付。用户真的点下支付以后,订单、支付和库存这几条链路就会同时动起来。
用户提交的是一次支付请求,支付平台回调的是一笔支付结果,订单服务需要更新订单状态,库存服务还要把预占的库存正式扣掉。它们发生的时间不一定相同,任何一步失败,都不能简单地把前面已经完成的事情当成没有发生过。
如果你介意下面的内容由 AI 生成,现在可以直接退出了。
这篇文章和前面的文章一样,业务思路来自这个商城项目。AI 帮我重新理解代码、补充实现过程,再把它整理成文章。当前项目里的支付服务使用本地模拟回调,没有接入支付宝或微信的真实接口。我们先把支付成功以后,订单和库存怎样继续往下走讲清楚。
一、支付服务接住的订单必须是待支付
第 14 篇里,订单创建以后会先进入“库存处理中”。库存服务预占成功,订单服务收到 inventory.locked 事件,订单状态才会变成“待支付”。
支付服务只接收这个状态的订单。
当前项目里的支付服务是一个单独的 Kratos 服务,端口是 50056,数据库使用 shop_payment。它通过 gRPC 调用订单服务,不直接读取订单数据库。
支付服务和订单服务各自拥有自己的数据。
- 订单服务保存订单金额、订单商品和订单状态;
- 支付服务保存支付单号、订单号、支付金额、支付渠道和第三方交易号;
- RabbitMQ 负责传递支付成功事件;
- 库存服务根据订单支付成功事件确认扣减库存。
支付服务创建支付单之前,会先查询订单。
order, err := s.orderRPC.GetOrder(ctx, &orderV1.GetOrderRequest{
UserId: req.UserId,
OrderSn: req.OrderSn,
})
if err != nil {
return nil, err
}
if order.Total != req.Amount {
return nil, kerrors.New(
400,
"PAYMENT_AMOUNT_MISMATCH",
"支付金额与订单金额不一致",
)
}
if order.Status != "待支付" {
return nil, kerrors.New(
400,
"ORDER_STATUS_INVALID",
"订单状态不允许支付",
)
}
这里有两个判断,顺序也很重要。
支付服务先通过订单服务确认订单属于当前用户,再检查客户端传来的金额。客户端带过来的金额只能用来发现前端显示和订单金额不一致,支付服务不会把它当成最终金额。
最终金额来自订单服务返回的 order.Total。订单服务里的金额来自创建订单时保存的商品快照,商品后来调价,不会影响已经生成的订单。
如果订单仍然是“库存处理中”,支付服务会拒绝创建支付单。库存没有预占成功以前,用户不应该先把钱付出去。要是允许这样做,后面库存失败、支付成功和退款就会同时出现,处理成本一下子就高了。
二、创建支付单
支付服务的接口定义比较简单。
service Payment {
rpc CreatePayment(CreatePaymentRequest) returns (PaymentInfoReply);
rpc PaymentCallback(PaymentCallbackRequest) returns (CheckReply);
}
message CreatePaymentRequest {
int64 userId = 1;
string orderSn = 2;
int64 amount = 3;
string channel = 4;
}
当前实现里,创建支付单的核心代码如下。
pay, err := s.uc.Create(ctx, &domain.Payment{
PaymentNo: generatePaymentNo(),
OrderSn: req.OrderSn,
UserID: req.UserId,
Amount: order.Total,
Channel: req.Channel,
})
if err != nil {
return nil, err
}
return &v1.PaymentInfoReply{
Id: pay.ID,
PaymentNo: pay.PaymentNo,
OrderSn: pay.OrderSn,
Amount: pay.Amount,
Channel: pay.Channel,
Status: int32(pay.Status),
}, nil
支付单号由支付服务生成,格式类似 PAY-20260816123456012345。金额从订单查询结果中取出来,渠道当前使用 mock。
支付表里的状态目前只用了两个值。
| 数值 | 状态 | 含义 |
|---|---|---|
| 1 | 待支付 | 支付单已经创建,等待模拟回调 |
| 2 | 已支付 | 支付回调确认成功 |
支付单和订单是两张表。订单号会同时出现在两边,支付服务通过订单号找到订单,但不会把订单表复制到自己的数据库里。
这里还有一个创建幂等的问题。
当前示例把重点放在支付回调幂等上。用户连续点击创建支付,代码会再次生成支付单。真实项目通常会给一个订单只保留一笔有效支付单,或者直接返回已有的待支付支付单。这个约束可以通过订单号唯一索引、支付单状态和一次性创建接口一起完成。
我暂时保留了当前项目的简单实现,先把跨服务的支付成功链路走通。支付单创建幂等属于下一轮补强时必须补上的内容,不能因为接口返回成功就假设用户只点了一次。
三、模拟支付回调
真实支付平台的流程大概是这样。
- 商城创建支付单;
- 支付服务向支付渠道发起支付;
- 用户在支付渠道完成付款;
- 支付渠道向商城发送回调;
- 商城验证回调以后更新支付单;
- 商城把支付成功传给订单和库存服务。
当前项目没有接入外部支付平台,所以第 2 步和第 4 步用一个 gRPC 接口模拟。
message PaymentCallbackRequest {
string paymentNo = 1;
string tradeNo = 2;
bool success = 3;
}
回调处理先根据支付单号查询本地支付单。
pay, err := s.uc.GetByPaymentNo(ctx, req.PaymentNo)
if err != nil {
return &v1.CheckReply{
Success: false,
Message: err.Error(),
}, nil
}
if !req.Success {
return &v1.CheckReply{
Success: false,
Message: "支付失败",
}, nil
}
支付成功以后,支付服务还要再次查询订单。订单可能已经被取消,也可能已经被其他回调处理完成。只有订单仍然处于“待支付”,这次回调才可以继续。
order, err := s.orderRPC.GetOrder(ctx, &orderV1.GetOrderRequest{
UserId: pay.UserID,
OrderSn: pay.OrderSn,
})
if err != nil {
return &v1.CheckReply{
Success: false,
Message: "订单不存在",
}, nil
}
if order.Status != "待支付" {
return &v1.CheckReply{
Success: false,
Message: "订单状态不允许支付",
}, nil
}
这一层判断不能省。支付服务只知道支付单已经收到成功通知,它还需要确认订单当前是否允许进入已支付状态。
真实支付回调还要验证签名、商户号、应用号、回调金额和第三方交易号。当前接口是本地模拟版本,调用方可以直接传 success: true,它只适合在本地把状态流转跑通,不能直接暴露给公网。
四、支付回调为什么要做条件更新
支付平台可能重复发送回调。
回调第一次到达时,支付单状态是 1,更新以后变成 2。回调第二次到达时,支付单已经是 2,服务应该返回成功,让支付平台停止重试,同时不能重复触发后面的业务。
数据层使用带状态条件的更新。
result := r.data.db.WithContext(ctx).
Model(&Payment{}).
Where("payment_no = ? AND status = 1", paymentNo).
Updates(map[string]interface{}{
"status": 2,
"trade_no": tradeNo,
"paid_at": now,
})
if result.Error != nil {
return false, result.Error
}
if result.RowsAffected == 0 {
var pay Payment
if err := r.data.db.
WithContext(ctx).
Where("payment_no = ?", paymentNo).
First(&pay).Error; err != nil {
return false, err
}
if pay.Status == 2 {
return false, nil
}
return false, kerrors.New(
400,
"PAYMENT_NOT_FOUND",
"支付单不存在",
)
}
这里的返回值 changed 很有用。
changed == true表示这次回调把支付单从待支付改成了已支付;changed == false表示支付单之前已经处理过;- 返回错误表示支付单不存在,或者数据库操作失败。
支付服务收到重复回调时,会返回“支付成功,重复回调已忽略”。它没有再次发送支付成功事件,订单服务也不会重复生成已支付事件。
支付回调的幂等点落在数据库的条件更新上。只在内存里判断状态不够,因为两个回调可能同时进来,最终判断必须交给数据库这一条更新语句。
五、支付成功以后不要直接改订单数据库
支付服务确认付款以后,订单服务还没有变成已支付。
这个过程看起来多绕了一步,实际是在保护服务边界。支付服务只负责支付单,订单服务只负责订单状态。支付服务不应该拿着订单表的连接去执行下面这条 SQL。
UPDATE orders SET order_status = 2 WHERE order_sn = '...';
当前支付服务会向 payment.exchange 发布 payment.success 事件,消息内容大致如下。
{
"payment_no": "PAY-20260816123456012345",
"order_sn": "20260816000001",
"user_id": 1,
"amount": 899900,
"trade_no": "MOCK-TRADE-001",
"paid_at": "2026-08-16T12:35:10+08:00"
}
订单服务启动时,会监听 q.payment.success 队列。
func (o *OrderService) handlePaymentSuccess(
ctx context.Context,
body []byte,
) error {
var evt paymentSuccessEvent
if err := json.Unmarshal(body, &evt); err != nil {
return err
}
if err := o.oc.MarkPaid(ctx, evt.OrderSn); err != nil {
o.log.Errorf(
"mark paid failed: order=%s err=%v",
evt.OrderSn,
err,
)
return err
}
o.log.Infof(
"order paid: %s trade=%s",
evt.OrderSn,
evt.TradeNo,
)
return nil
}
MarkPaid 会做两件事。
第一件事是把订单状态从待支付改成已支付。第二件事是创建 order.paid Outbox 事件。
changed, err := oc.repo.UpdateStatusIf(
ctx,
orderSn,
domain.OrderStatusPendingPayment,
domain.OrderStatusPaid,
)
if err != nil {
return err
}
if !changed {
return nil
}
eventID := generateEventID()
outbox := &domain.OutboxEvent{
EventID: eventID,
EventType: "order.paid",
OrderSn: orderSn,
Payload: buildOrderEventPayload(
eventID,
"order.paid",
orderSn,
items[0].UserId,
items,
),
}
return oc.repo.CreateOutbox(ctx, outbox)
当前代码会先更新订单状态,再单独写入 Outbox。两次写入还没有放进同一个订单数据库事务里。订单状态已经变成已支付,订单服务还需要继续写入 order.paid,这中间存在进程退出的时间窗口。
因此,支付服务只需要发布支付结果,订单服务根据自己的数据决定能不能把订单推进到已支付。后续补可靠性时,要把状态更新和 Outbox 写入合并成一次事务,避免订单已经支付却没有留下 order.paid 事件。
六、库存服务最后确认扣减
订单服务发布 order.paid 以后,库存服务会收到这条事件。库存服务在第 13 篇里已经实现了三种动作,支付成功会使用其中的 ConfirmDeduct。
TryLock预占库存;ConfirmDeduct确认扣减;Release释放预占。
if evt.EventType == "order.paid" {
routingKey = "inventory.deducted"
opErr = s.uc.ConfirmDeduct(ctx, evt.OrderSn, items)
if opErr != nil {
routingKey = "inventory.deduct.failed"
reason = opErr.Error()
s.log.Errorf(
"confirm deduct failed: order=%s err=%v",
evt.OrderSn,
opErr,
)
}
}
确认扣减会在库存服务自己的数据库事务里完成。
err := r.data.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range items {
var lock InventoryLock
if err := tx.
Where(
"order_sn = ? AND sku_id = ? AND status = 1",
orderSn,
item.SkuID,
).
First(&lock).Error; err != nil {
return kerrors.New(
400,
"LOCK_NOT_FOUND",
"预占记录不存在",
)
}
if err := tx.Model(&Inventory{}).
Where("sku_id = ?", item.SkuID).
Updates(map[string]interface{}{
"inventory": gorm.Expr(
"inventory - ?",
item.Num,
),
"locked": gorm.Expr(
"locked - ?",
item.Num,
),
"version": gorm.Expr(
"version + 1",
),
"updated_at": time.Now(),
}).Error; err != nil {
return err
}
if err := tx.Model(&InventoryLock{}).
Where("id = ?", lock.ID).
Update("status", 2).Error; err != nil {
return err
}
}
return nil
})
预占记录从状态 1 变成状态 2,库存总量减少,锁定数量也减少。可用库存的计算结果自然会发生变化。
库存服务还会写入消费事件记录。RabbitMQ 可能重复投递 order.paid,消费记录可以让同一条事件只被业务处理一次。数据库事务失败时,事件不会被标记为已消费,消息会继续走重试流程。
到这里,支付成功这条流程才算走完。
创建订单
↓
库存预占
↓
订单进入待支付
↓
支付服务创建支付单
↓
模拟支付回调
↓
支付单变成已支付
↓ payment.success
订单服务把订单改成已支付
↓ order.paid
库存服务确认扣减
↓
库存预占记录结束
用户看到的支付成功页面,和库存服务完成确认扣减之间可能有一点时间差。这是消息队列带来的最终一致性,也是当前服务拆分以后必须接受的现实。
七、消息成功不等于业务一定完成
这里有一个需要把话说透的地方。
当前项目的支付服务在本地支付单更新成功以后,直接调用 RabbitMQ 发布 payment.success。如果支付单已经写成已支付,RabbitMQ 恰好在这时连接失败,支付服务就会返回“支付成功但事件发布失败”。
这会留下一个时间窗口。支付单已经是已支付,订单还停在待支付。如果支付平台随后重试回调,支付服务会发现支付单已经处理过,直接返回重复成功,不会再次发送事件。
这个问题在当前示例里还没有用支付 Outbox 解决。订单服务已经有 Outbox,支付服务还没有。真实系统里,支付服务也应该在自己的数据库事务中写入支付成功事件,再由独立的 Relay 投递到 RabbitMQ。
我把这个边界留在这里,是因为这篇文章先把支付、订单和库存三段业务接通。要把支付 Outbox、支付渠道签名、回调重试和人工补偿一起补齐,内容会再展开一层,放到后续的可靠性文章里更合适。
写商城代码时,最怕看到某个接口返回成功,就默认所有服务都已经成功。支付服务的成功只说明支付单完成了,订单和库存还要沿着自己的事件继续处理。
八、支付失败和订单超时
当前模拟接口收到 success: false 时,会直接返回支付失败,支付单仍然保持待支付。
这样做是为了让订单继续拥有一次支付机会。用户可以重新发起支付,也可以等订单超时关闭。支付失败本身没有库存动作,因为库存还在预占状态,订单只要仍然没有支付成功,后续就需要走取消和释放流程。
订单服务里已经有超时扫描逻辑。它会找到超过支付时间的待支付订单,调用取消订单逻辑。取消订单会发布 order.cancelled,库存服务收到以后执行 Release。
这条链路和支付成功正好相反。
待支付订单超时
↓
订单变成已取消
↓ order.cancelled
库存服务释放预占
↓
可用库存恢复
支付成功回调和订单超时任务可能同时发生。订单服务使用状态条件更新,只有待支付订单能变成已支付或已取消,谁先成功更新,谁就赢得这次状态转换。后到的一方会发现状态已经不符合条件,再按幂等或失败路径返回。
这也是订单状态不能只在接口层判断的原因。并发保护仍然要放到数据库更新条件里。
九、本地运行和调用
支付服务依赖 PostgreSQL、Consul、RabbitMQ 和订单服务。配置文件在 service/payment/configs/config.yaml。
server:
grpc:
addr: 0.0.0.0:50056
data:
database:
driver: postgres
source: postgres://postgres:root@127.0.0.1:5432/shop_payment?sslmode=disable&TimeZone=Asia/Shanghai
mq:
addr: amqp://root:root@127.0.0.1:5672/
service:
order:
endpoint: discovery:///shop.order.service
先启动基础设施和前面的服务,再启动支付服务。
make infra-up
make migrate-up
cd service/payment
go run ./cmd/payment -conf ./configs
创建支付单需要使用一个已经进入“待支付”的订单。
grpcurl -plaintext \
-import-path api \
-proto payment/v1/payment.proto \
-d '{
"userId": 1,
"orderSn": "20260816000001",
"amount": 899900,
"channel": "mock"
}' \
127.0.0.1:50056 \
payment.v1.Payment/CreatePayment
接口会返回 paymentNo。把返回的支付单号放进模拟回调请求。
grpcurl -plaintext \
-import-path api \
-proto payment/v1/payment.proto \
-d '{
"paymentNo": "PAY-20260816123456012345",
"tradeNo": "MOCK-TRADE-001",
"success": true
}' \
127.0.0.1:50056 \
payment.v1.Payment/PaymentCallback
回调接口返回成功以后,不要立刻假设订单查询已经变成已支付。支付成功事件还要经过 RabbitMQ、订单服务和订单 Outbox。稍等片刻,再调用订单查询接口,订单状态会从“待支付”变成“已支付”。
库存服务收到 order.paid 后,还会继续确认扣减。这个过程可以通过库存查询和库存流水观察。
如果创建支付时返回 PAYMENT_AMOUNT_MISMATCH,先检查订单详情里的金额。金额单位是分,899900 表示 8999.00 元。
如果返回 ORDER_STATUS_INVALID,说明订单还在库存处理中,或者已经被支付、取消。先查订单状态,不要靠重复调用支付接口碰运气。
十、测试支付链路
支付服务当前的单元测试放在 service/payment/internal/service/payment_callback_test.go 和 service/payment/internal/service/payment_test.go,下面这条命令可以运行不依赖真实数据库的测试。
cd service/payment
go test -race ./internal/service
测试覆盖了这些情况。
- 支付金额和订单金额不一致;
- 库存还在处理时拒绝支付;
- 支付单不存在;
- 支付回调返回失败;
- 订单已经支付以后拒绝再次支付;
- 第一次支付回调成功;
- 重复支付回调被忽略;
- 支付单号能够正常生成。
支付数据层测试需要本地 PostgreSQL。
cd service/payment
go test ./internal/data
这组测试会清理 payments 表,基础设施没有启动时,失败信息通常会先指向数据库连接。先看依赖,再判断是不是业务代码的问题。
单元测试只能证明状态转换函数符合预期。完整验证还要启动订单、支付、库存和 RabbitMQ,亲自走一遍创建订单、预占库存、创建支付单、回调支付和确认扣减。
结束语
支付服务这一篇没有接真实的第三方支付渠道。当前项目先用一个本地模拟回调,把支付单、订单和库存三段流程接起来。
支付服务负责确认支付单,订单服务负责确认订单状态,库存服务负责确认仓库数量。它们之间通过 gRPC 查询必要的信息,再用 RabbitMQ 传递状态变化。
这条链路里有几个判断值得一直保留。
- 支付金额以订单服务返回的金额为准;
- 库存处理中不能创建支付单;
- 支付回调必须支持重复到达;
- 支付服务不能直接修改订单数据库;
- 订单服务收到支付成功以后,还要通过自己的 Outbox 发布
order.paid; - 库存只有在订单支付成功以后才正式扣减。
到这里,用户已经可以从购物车下单、预占库存、创建支付单,再把订单推进到已支付。接下来还要处理订单超时、取消、库存释放和支付回调晚到这些不太听话的情况。
下一篇就写订单关闭和库存释放。支付成功和订单取消往往会在很接近的时间发生,麻烦也从这里开始。
这里特别感谢一下一直支持观看此系列的同学,更感谢你们的打赏、点赞、分享

感谢您的耐心阅读,动动手指点个赞吧。
本作品采用《CC 协议》,转载必须注明作者和本文链接
关于 LearnKu
推荐文章: