一次重复回调,发了两份货
《泡泡工坊》上线首日,一名玩家买了 6 元的新手礼包。渠道在 20 秒内重投了三次支付成功回调,服务端每次都给同一个角色发了礼包。玩家没有声张,直到第二天客服对账时才发现:礼包发放数量比订单数多了 37 份,涉及 12 个账号。
排查结果并不复杂:回调处理没有做幂等,渠道重试被当成了新订单。真正麻烦的是善后——哪些礼包已经被消耗、哪些转成了游戏内货币、要不要回收、回收后玩家余额会不会变成负数。团队花了三天写脚本,并给其中两个账号手工补了“误扣”说明。
支付链路的难点从来不是“收到钱”,而是同一笔钱在不可靠的网络上被反复通知时,服务端仍然只认一次。渠道会重试,网关会重投,消息队列至少一次投递,运维还会手工补单;任何一处都可能把同一条回调送到业务代码面前。
支付链路只有五个动作
北辰工作室三款产品的支付链路可以抽象成同一张图:
客户端 游戏服务端 支付渠道
| 下单 | |
|----------------->| 1. 创建订单 created |
| |---------------------------->| 2. 预下单
| |<----------------------------| 渠道单号/支付页
| 支付 | |
|----------------------------------------------->|
| |<----------------------------| 3. 回调(事件ID+签名)
| | 4. 验签 -> 去重 -> 置 paid |
| | 发货 -> 置 delivered |
|<-----------------| 5. 通知客户端 |
每一步都有独立的失败态:
| 步骤 | 失败表现 | 订单状态 | 处理 |
|---|---|---|---|
| 创建订单 | 商品不存在、价格异常 | 不创建 | 拒绝下单,不落脏数据 |
| 预下单 | 渠道超时、渠道单号未返回 | created | 可重试,客户端轮询 |
| 回调验签 | 签名错误、字段缺失 | 不变 | 拒绝并告警,不泄露订单信息 |
| 发货 | 背包满、库存服务超时 | paid | 保留 paid,等下一次回调重试 |
| 通知客户端 | 连接断开、客户端崩溃 | delivered | 客户端主动拉取,不依赖推送 |
这张表决定了状态机的形状:created -> paid -> delivered 是主路径,created -> closed 是未支付关单;paid 不是终点,因为发货可能失败;delivered 才代表玩家真正拿到了东西。
订单号与渠道单号不是一回事
支付系统里至少有三个标识,混用会直接破坏幂等:
| 标识 | 谁生成 | 作用 | 能否复用 |
|---|---|---|---|
内部订单号 OrderID | 游戏服务端 | 业务主键,发货与客服都认它 | 永不复用 |
渠道单号 ChannelOrderID | 支付渠道 | 渠道侧对账、退款、查询的键 | 一个订单一个 |
事件号 EventID | 支付渠道 | 标识“这一次通知” | 渠道可能对同一事件重投 |
渠道重试时通常保持 EventID 不变;但也有渠道每次重试生成新的事件号。因此只按事件去重不够,还要按订单状态兜底:同一订单已经 delivered,后续任何支付成功回调都只返回成功,不再发货。
异常回调要分四类识别:
| 异常 | 识别方式 | 处置 |
|---|---|---|
| 重复 | 事件号已消费,或订单已 delivered | 返回成功,不重复发货 |
| 乱序 | 关单回调晚于支付回调 | 已支付/已发货后忽略关单 |
| 伪造 | 签名校验失败、渠道与订单不匹配 | 拒绝并告警,状态不变 |
| 篡改 | 回调金额或币种与订单不一致 | 拒绝,转人工核查 |
最危险的是“伪造 + 重放”组合:攻击者拿到一个真实事件号,反复发送篡改金额的回调。如果先做事件去重再验签,第一次伪造就会被当成重复而放过。正确顺序是先验签、再查重。
决策:金额、时机与幂等键
| 决策点 | 方案 A | 方案 B | 北辰选择 | 理由 |
|---|---|---|---|---|
| 金额来源 | 客户端上报 | 服务端商品表 | B | 客户端可篡改;渠道回调金额只用于核对 |
| 发货时机 | 回调内同步发货 | 先回 ACK 再异步消费 | A(轻量商品) | 链路短、状态简单;重商品用 outbox 另说 |
| 幂等键 | 只用订单号 | 订单号 + 事件号 | 两者都用 | 事件号挡住重投,订单状态挡住换事件号的重试 |
| 重复回调响应 | 返回失败让渠道继续重试 | 返回成功并忽略 | B | 渠道重试有上限,继续失败会把正常订单拖成异常 |
| 关单乱序 | 用关单覆盖已支付状态 | 已支付后忽略关单 | B | 钱已经收了,覆盖状态会造成“收款未发货” |
| 订单号来源 | 渠道生成 | 内部生成 | B | 内部主键不能依赖外部系统的可用性 |
同步发货的边界要说清楚:它适合发货动作在几十毫秒内完成、且发货接口自身幂等的商品。如果发货要跨多个服务、可能阻塞数秒,就不要占着回调连接,应该落一张 outbox 表,由消费者重试。北辰三款产品当前都走同步发货,但《群岛纪元》的大额礼包已经在评估 outbox。
选定方案与边界
《泡泡工坊》最终采用“服务端定价 + 先验签 + 双层幂等 + 可重试发货”的方案:
- 价格只认服务端商品表;回调金额与币种必须和订单一致,否则拒绝。
- 事件号按“渠道 + 事件号”去重;订单按状态去重。两层都通过才允许发货。
- 发货失败时订单停在
paid,事件不标记为已消费,下一次回调继续重试。 - 发货成功后订单进入
delivered,再收到支付回调只返回成功。 - 已支付或已发货后收到关单回调,标记为忽略,不改变状态。
- 渠道单号在第一次验签通过的回调上绑定,之后不允许换成另一个渠道单号。
这里刻意没有处理退款。退款会引入 refunded 状态和资产回收,放到 11.2 讨论;支付与退款共享订单主键,但不共享状态机。
可运行片段:回调、验签与幂等发货
下面的 internal/paydelivery/callback.go 是实验室包的核心文件。order.go 定义订单、回调、渠道接口和内存存储;callback.go 只负责“一条回调进来之后发生什么”。渠道验签是接口桩,不接真实 SDK,也不含真实商户号或密钥。
// file: internal/paydelivery/callback.go
// tier: L1
// verify: go test ./internal/paydelivery/
package paydelivery
import "fmt"
// Handle verifies before deduplicating, so a forged replay of a real event ID
// is still rejected.
func (s *Service) Handle(n Notification) (Result, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := validateNotification(n); err != nil {
return Result{}, err
}
if s.verifier == nil {
return Result{}, fmt.Errorf("%w: no verifier", ErrBadSignature)
}
if err := s.verifier.Verify(n); err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrBadSignature, err)
}
key := n.Channel + "\x00" + n.EventID
if _, ok := s.seen[key]; ok {
o, _ := s.orders[n.OrderID]
if o == nil {
return Result{Duplicate: true}, nil
}
return Result{Order: *o, Duplicate: true}, nil
}
o, ok := s.orders[n.OrderID]
if !ok {
return Result{}, fmt.Errorf("%w: %s", ErrOrderNotFound, n.OrderID)
}
if o.Channel != n.Channel {
return Result{Order: *o}, fmt.Errorf("%w: %s", ErrChannelMismatch, n.Channel)
}
if o.ChannelOrderID == "" {
o.ChannelOrderID = n.ChannelOrderID
} else if o.ChannelOrderID != n.ChannelOrderID {
return Result{Order: *o}, fmt.Errorf("%w: %s", ErrChannelMismatch, n.ChannelOrderID)
}
switch n.Status {
case StatusPaid:
return s.handlePaid(n, o, key)
case StatusClosed:
return s.handleClosed(o, key)
default:
return Result{Order: *o}, fmt.Errorf("%w: %s", ErrInvalidStatus, n.Status)
}
}
func (s *Service) handlePaid(n Notification, o *Order, key string) (Result, error) {
if o.AmountCents != n.AmountCents || o.Currency != n.Currency {
return Result{Order: *o}, fmt.Errorf("%w: got %d %s, want %d %s",
ErrAmountMismatch, n.AmountCents, n.Currency, o.AmountCents, o.Currency)
}
switch o.State {
case StateDelivered:
s.seen[key] = struct{}{}
return Result{Order: *o, Duplicate: true}, nil
case StateClosed:
return Result{Order: *o}, fmt.Errorf("%w: paid after closed", ErrInvalidState)
case StateCreated:
o.State = StatePaid
if n.OccurredAt.IsZero() {
o.PaidAt = s.now()
} else {
o.PaidAt = n.OccurredAt
}
case StatePaid:
// Payment recorded but delivery failed earlier; retry it.
default:
return Result{Order: *o}, fmt.Errorf("%w: %s", ErrInvalidState, o.State)
}
if s.deliverer == nil {
return Result{Order: *o}, fmt.Errorf("%w: no deliverer", ErrDeliveryFailed)
}
if err := s.deliverer.Deliver(*o); err != nil {
return Result{Order: *o}, fmt.Errorf("%w: %v", ErrDeliveryFailed, err)
}
o.State = StateDelivered
o.DeliveredAt = s.now()
s.seen[key] = struct{}{}
return Result{Order: *o, Delivered: true}, nil
}
func (s *Service) handleClosed(o *Order, key string) (Result, error) {
s.seen[key] = struct{}{}
switch o.State {
case StateCreated:
o.State = StateClosed
return Result{Order: *o}, nil
case StateClosed:
return Result{Order: *o, Duplicate: true}, nil
default:
// Money already moved; a late close must not undo a delivered order.
return Result{Order: *o, Ignored: true}, nil
}
}
func validateNotification(n Notification) error {
if n.EventID == "" || n.Channel == "" || n.OrderID == "" || n.ChannelOrderID == "" {
return fmt.Errorf("%w: missing event, channel, order or channel order id", ErrBadRequest)
}
if n.Status != StatusPaid && n.Status != StatusClosed {
return fmt.Errorf("%w: %s", ErrInvalidStatus, n.Status)
}
return nil
}
几个关键点:
- 先验签再去重。 伪造的事件号不会因为“看起来重复”而被放过。
- 事件去重和订单状态是两层。
seen挡住同一事件重投;StateDelivered挡住渠道换事件号的重试。 - 发货失败不消费事件。
seen[key]只在发货成功后写入,所以同一事件重投会再次尝试发货。 - 发货器必须自己幂等。 服务层只能保证“尽量只调用一次”,进程崩溃在发货与落账之间时,重试仍可能重复调用;最终防线是发货接口按订单号去重。
- 关单不覆盖已支付。
handleClosed对paid/delivered返回Ignored,避免晚到的关单把已收款订单改回关闭。 - 金额校验在状态迁移之前。 金额不符时订单状态完全不变,后续人工核查有干净的事实基础。
配套测试覆盖了下单参数、九类非法回调、同事件与换事件重投、关单乱序、两次发货失败后的重试,以及 50 个并发重复回调只发货一次。测试使用固定时钟和确定性 ID,不依赖真实渠道。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 渠道重复回调 | 同一订单多次发货 | 事件去重 + 订单状态去重 |
| 渠道换事件号重试 | 事件号不同、订单已发货 | 已 delivered 直接返回成功 |
| 回调伪造 | 金额被改大、订单号被换 | 验签失败即拒绝,记录告警 |
| 回调乱序 | 关单先到、支付后到 | 关单只关闭 created;支付后关单忽略 |
| 发货失败 | 背包满、库存服务超时 | 订单停 paid,下一次回调重试 |
| 发货成功但落账前崩溃 | 玩家有货、订单仍 paid | 发货接口按订单号幂等,重试只补状态 |
| 渠道单号冲突 | 同一订单收到两个渠道单号 | 第二次绑定直接拒绝 |
| 客户端通知失败 | 玩家没看到到账动画 | 客户端登录/进入商店时主动查询订单 |
前七种都能在服务端闭环,最后一种必须靠客户端补偿查询。不要把“通知客户端”当成支付成功的一部分;它只是体验优化。
常见错误与边界
第一,用客户端传来的金额下单。价格必须由服务端商品表决定,回调金额只用于核对。
第二,先查重再验签。攻击者可以复用真实事件号,让伪造回调被误判为重复。
第三,发货失败后把事件标记为已处理。这样渠道不再重试,订单永远停在 paid。
第四,把渠道单号当内部订单号。渠道换供应商或迁移商户号时,历史订单会对不上。
第五,收到关单就无条件关闭订单。支付回调可能晚于关单到达,覆盖状态会造成收款未发货。
第六,以为“只调用一次”能靠代码分支保证。网络超时、进程崩溃、消息重投都会打破它,幂等必须落在数据和接口上。
小结
支付回调的正确性不取决于渠道多可靠,而取决于服务端是否把“同一笔钱只发一次货”写成可验证的不变量:服务端定价、先验签、事件与订单双层去重、发货失败可重试、已支付订单不被关单覆盖。把这些做成一个独立的发货组件,客服和对账才有稳定的订单事实。
支付解决“钱怎么进来”。下一节处理反方向:钱怎么退回去,以及渠道账单和内部订单对不上时,差异如何分类、冲正和挂账。
阅读导航:上一节:10.3 批量领取、补偿与红点 · 下一节:11.2 退款、对账与差异处理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。