Go-kratos 框架商城微服务实战之支付服务 (十五)

AI摘要
【知识分享】本文详细解析了电商系统中支付成功后的订单与库存处理流程,涵盖支付服务创建支付单、模拟回调、条件更新幂等机制,以及通过RabbitMQ事件驱动订单状态更新和库存确认扣减的完整链路。文章强调服务间数据隔离、状态条件更新和最终一致性,并指出支付Outbox等可靠性边界待后续完善。

上一篇文章把订单从购物车带到了待支付。

这个状态看起来很平静。订单已经写进数据库,库存也已经预占成功,用户只需要点一下支付。用户真的点下支付以后,订单、支付和库存这几条链路就会同时动起来。

用户提交的是一次支付请求,支付平台回调的是一笔支付结果,订单服务需要更新订单状态,库存服务还要把预占的库存正式扣掉。它们发生的时间不一定相同,任何一步失败,都不能简单地把前面已经完成的事情当成没有发生过。

如果你介意下面的内容由 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 已支付 支付回调确认成功

支付单和订单是两张表。订单号会同时出现在两边,支付服务通过订单号找到订单,但不会把订单表复制到自己的数据库里。

这里还有一个创建幂等的问题。

当前示例把重点放在支付回调幂等上。用户连续点击创建支付,代码会再次生成支付单。真实项目通常会给一个订单只保留一笔有效支付单,或者直接返回已有的待支付支付单。这个约束可以通过订单号唯一索引、支付单状态和一次性创建接口一起完成。

我暂时保留了当前项目的简单实现,先把跨服务的支付成功链路走通。支付单创建幂等属于下一轮补强时必须补上的内容,不能因为接口返回成功就假设用户只点了一次。

三、模拟支付回调

真实支付平台的流程大概是这样。

  1. 商城创建支付单;
  2. 支付服务向支付渠道发起支付;
  3. 用户在支付渠道完成付款;
  4. 支付渠道向商城发送回调;
  5. 商城验证回调以后更新支付单;
  6. 商城把支付成功传给订单和库存服务。

当前项目没有接入外部支付平台,所以第 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.goservice/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 协议》,转载必须注明作者和本文链接
微信搜索:上帝喜爱笨人
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!