《游戏服务端架构·核心系统与契约》11.1 支付渠道与发货幂等

《泡泡工坊》上线首日,支付渠道重复回调让同一笔 6 元订单发了两次货,客服只能按截图手工补单。本节拆解下单、回调、验签、发货与通知的状态机,比较同步发货和先记账后异步发货,说明订单号与渠道单号的绑定、事件去重与发货重试的边界,并给出可运行的 Go 回调处理与幂等发货片段。

一次重复回调,发了两份货

《泡泡工坊》上线首日,一名玩家买了 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 退款、对账与差异处理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查