《游戏服务端编程实践》9.3.3 商城与交易系统

商城和玩家交易是货币离开与重新分配的主要通道。本篇讨论商品、限购、上下架与渠道差异,订单为什么必须按业务单号幂等;玩家交易用托管状态机保证双方原子性,并给出交易锁、二次确认和异常价格检测。Go 实现包含幂等 Buy 与托管 Trade,测试覆盖重复下单、余额不足和撤销退款。

一、商城的四条硬规则

商城不是“把商品列出来,点一下扣钱”。真正决定它是否可靠的是四条服务端规则:

规则要解决的问题错误做法
价格由服务端决定客户端改价、渠道折扣不一致相信客户端传来的金额
订单按业务单号幂等超时重试、弱网重发导致重复发货每次请求都新建订单
限购按服务端计数客户端清缓存绕过限购用本地存档记录购买次数
上下架即时生效活动结束后仍能买到旧商品只改前端配置,不校验服务端状态

渠道差异要落在“支付侧”而不是“商品侧”。同一个商品在 iOS、安卓、网页上的价格展示可以不同,但服务端校验的必须是渠道回调里的真实金额和币种,不能拿展示价当结算价。

二、订单幂等:先查单,再扣钱

订单的幂等键是业务单号,不是客户端生成的随机数。客户端重试时必须复用同一个单号;服务端收到已存在的单号,直接返回原订单,不再扣款、不再发货。

顺序必须是“查单 → 校验商品与限购 → 扣款 → 落单 → 发货”。如果先扣款再落单,进程在两步之间崩溃,玩家会丢钱;如果先发货再扣款,余额不足时货已经出去了。落单和扣款要尽量放在同一个本地事务里,发货可以用订单状态驱动重试。

订单状态至少要有“已支付”和“已发货”两个阶段。发货失败时,订单保持已支付并进入重试队列,而不是把订单删掉让玩家重新买。

三、玩家交易:面对面与拍卖行

玩家交易的核心不是“把 A 的物品给 B”,而是“双方都不吃亏”。面对面交易和拍卖行只是入口不同,底层都需要托管。

模式托管内容手续费典型风险
面对面卖方物品 + 买方货币可选,按成交额一方确认后掉线,另一方催促
拍卖行挂单物品 + 买方出价成交税、上架费虚假高价、洗钱、恶意抬价
邮件交易附件物品 + 货款按邮件规则附件过期、重复领取

托管状态机的状态应当是单向的:open → escrowed → completed,或者 open/escrowed → cancelled。已经完成的交易不能再撤销,已经撤销的不能再完成。每次状态迁移都要校验前置状态,否则重复请求会把同一笔交易结算两次。

四、防欺诈:锁、确认与价格检测

交易欺诈很少是“破解协议”,更多是流程漏洞:

  • 交易锁:同一玩家同一时间只能有一笔进行中的交易,防止用两个客户端同时操作同一份资产。
  • 二次确认:双方都确认后才进入托管,确认后修改报价必须重新确认。
  • 异常价格检测:成交价显著偏离市场价时拦截或进入人工审核,尤其是新账号之间的大额交易。
  • 冷却与限额:新设备、新好友、异常登录后的交易设置冷却期和单日上限。
  • 完整审计:记录双方 ID、物品、价格、手续费、IP 和设备指纹,事后可追溯。

价格检测不要只做上限。低价转移同样常见:把高价值物品以极低价格卖给小号,再由小号转卖。阈值应该基于同物品近期的成交分位数,而不是一个固定数字。

五、失败场景:重复下单发了两份货

某次大促,支付回调因为网络抖动重试了三次。服务端的发货逻辑每次收到回调都新建订单并扣一次库存,结果同一个支付单发了三份礼包。玩家没有主动利用漏洞,但库存和货币已经错了。

根因是订单表用自增 ID 做主键,支付流水号只存在日志里。回调重试时服务端查不到“这个支付流水是否处理过”,于是又走了一遍发货。修复方式是把支付流水号作为唯一幂等键,回调先查订单,已存在就直接返回成功。

另一类事故发生在玩家交易:买方先确认,服务端立刻把物品给买方,再向卖方扣物品。卖方在两步之间下线或物品已被转移,扣减失败,买方却已经拿到货。托管的意义就是先把双方资产移入中间状态,全部成功后再一次性结算。

六、Go 实现:幂等 Shop 与托管 TradeManager

Shop.Buy 以订单 ID 为幂等键,重复请求返回原订单,不会二次扣款;限购计数保存在服务端。TradeManager 用 open/escrowed/completed/cancelled 四态管理交易,托管阶段先校验余额和物品,再一次性移动双方资产。

// file: ch09/economy/shop.go
// tier: L1
// verify: go test ./ch09/economy/

package economy

import (
	"errors"
	"math"
	"sync"
)

var (
	ErrProductExists     = errors.New("shop: product already exists")
	ErrProductNotFound   = errors.New("shop: product not found")
	ErrProductInactive   = errors.New("shop: product inactive")
	ErrPurchaseLimit     = errors.New("shop: purchase limit reached")
	ErrOrderConflict     = errors.New("shop: order id reused with different content")
	ErrTradeExists       = errors.New("trade: id already exists")
	ErrTradeNotFound     = errors.New("trade: not found")
	ErrInvalidTradeState = errors.New("trade: invalid state transition")
	ErrItemNotOwned      = errors.New("trade: seller does not own the item")
)

type Product struct {
	ID             string
	Currency       Currency
	UnitPrice      int64
	LimitPerPlayer int // 0 表示不限购
	Active         bool
}

type Order struct {
	ID        string
	Player    string
	ProductID string
	Amount    int64
}

// Shop 用订单 ID 做幂等键:同一订单重放只返回原订单,不会重复扣款。
type Shop struct {
	mu        sync.Mutex
	products  map[string]Product
	orders    map[string]Order
	purchased map[string]int
}

func NewShop() *Shop {
	return &Shop{
		products:  make(map[string]Product),
		orders:    make(map[string]Order),
		purchased: make(map[string]int),
	}
}

func (s *Shop) AddProduct(p Product) error {
	if p.ID == "" || p.UnitPrice <= 0 || p.LimitPerPlayer < 0 {
		return ErrInvalidEntry
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.products[p.ID]; ok {
		return ErrProductExists
	}
	s.products[p.ID] = p
	return nil
}

func (s *Shop) Buy(w *Wallet, orderID, player, productID string) (Order, error) {
	if orderID == "" || player == "" {
		return Order{}, ErrInvalidEntry
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if old, ok := s.orders[orderID]; ok {
		if old.Player == player && old.ProductID == productID {
			return old, nil
		}
		return Order{}, ErrOrderConflict
	}
	p, ok := s.products[productID]
	if !ok {
		return Order{}, ErrProductNotFound
	}
	if !p.Active {
		return Order{}, ErrProductInactive
	}
	key := player + "\x00" + productID
	if p.LimitPerPlayer > 0 && s.purchased[key] >= p.LimitPerPlayer {
		return Order{}, ErrPurchaseLimit
	}
	if err := w.Debit(p.Currency, p.UnitPrice); err != nil {
		return Order{}, err
	}
	o := Order{ID: orderID, Player: player, ProductID: productID, Amount: p.UnitPrice}
	s.orders[orderID] = o
	s.purchased[key]++
	return o, nil
}

// Inventory 按“玩家 + 物品”计数,用于演示交易托管。
type Inventory struct {
	items map[string]int
}

func NewInventory() *Inventory { return &Inventory{items: make(map[string]int)} }

func invKey(player, item string) string { return player + "\x00" + item }

func (inv *Inventory) Add(player, item string, n int) {
	if n > 0 {
		inv.items[invKey(player, item)] += n
	}
}

func (inv *Inventory) Remove(player, item string, n int) bool {
	k := invKey(player, item)
	if n <= 0 || inv.items[k] < n {
		return false
	}
	inv.items[k] -= n
	return true
}

func (inv *Inventory) Count(player, item string) int { return inv.items[invKey(player, item)] }

type TradeState int

const (
	TradeOpen TradeState = iota
	TradeEscrowed
	TradeCompleted
	TradeCancelled
)

type Trade struct {
	ID       string
	Seller   string
	Buyer    string
	Item     string
	Currency Currency
	Price    int64
	FeeBPS   int64
	State    TradeState
}

type TradeManager struct {
	mu     sync.Mutex
	trades map[string]*Trade
}

func NewTradeManager() *TradeManager { return &TradeManager{trades: make(map[string]*Trade)} }

func bpsFee(amount, bps int64) int64 {
	return amount/10000*bps + (amount%10000)*bps/10000
}

func (t *Trade) total() int64 { return t.Price + bpsFee(t.Price, t.FeeBPS) }

// Open 只登记交易意向,不移动任何资产。
func (m *TradeManager) Open(id, seller, buyer, item string, currency Currency, price, feeBPS int64) (Trade, error) {
	if id == "" || seller == "" || buyer == "" || item == "" || seller == buyer {
		return Trade{}, ErrInvalidEntry
	}
	if price <= 0 || feeBPS < 0 || feeBPS > 10000 {
		return Trade{}, ErrInvalidEntry
	}
	if price > math.MaxInt64-bpsFee(price, feeBPS) {
		return Trade{}, ErrAmountOverflow
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	if _, ok := m.trades[id]; ok {
		return Trade{}, ErrTradeExists
	}
	t := &Trade{ID: id, Seller: seller, Buyer: buyer, Item: item, Currency: currency, Price: price, FeeBPS: feeBPS}
	m.trades[id] = t
	return *t, nil
}

// Escrow 先校验买方余额和卖方物品,再一次性扣款并移走物品。
func (m *TradeManager) Escrow(id string, inv *Inventory, buyer *Wallet) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	t, ok := m.trades[id]
	if !ok {
		return ErrTradeNotFound
	}
	if t.State != TradeOpen {
		return ErrInvalidTradeState
	}
	if buyer.Balance(t.Currency) < t.total() {
		return ErrInsufficientBalance
	}
	if inv.Count(t.Seller, t.Item) < 1 {
		return ErrItemNotOwned
	}
	if err := buyer.Debit(t.Currency, t.total()); err != nil {
		return err
	}
	inv.Remove(t.Seller, t.Item, 1)
	t.State = TradeEscrowed
	return nil
}

// Complete 把托管物品交给买方,货款给卖方,手续费销毁。
func (m *TradeManager) Complete(id string, inv *Inventory, sellerWallet *Wallet) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	t, ok := m.trades[id]
	if !ok {
		return ErrTradeNotFound
	}
	if t.State != TradeEscrowed {
		return ErrInvalidTradeState
	}
	if sellerWallet.Balance(t.Currency) > math.MaxInt64-t.Price {
		return ErrAmountOverflow
	}
	if err := sellerWallet.Credit(t.Currency, t.Price); err != nil {
		return err
	}
	inv.Add(t.Buyer, t.Item, 1)
	t.State = TradeCompleted
	return nil
}

// Cancel 撤销交易:未托管只改状态,已托管则退款并退回物品。
func (m *TradeManager) Cancel(id string, inv *Inventory, buyerWallet *Wallet) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	t, ok := m.trades[id]
	if !ok {
		return ErrTradeNotFound
	}
	switch t.State {
	case TradeOpen:
		t.State = TradeCancelled
		return nil
	case TradeEscrowed:
		if buyerWallet.Balance(t.Currency) > math.MaxInt64-t.total() {
			return ErrAmountOverflow
		}
		if err := buyerWallet.Credit(t.Currency, t.total()); err != nil {
			return err
		}
		inv.Add(t.Seller, t.Item, 1)
		t.State = TradeCancelled
		return nil
	default:
		return ErrInvalidTradeState
	}
}

Escrow 在任何一步失败时都不修改余额和库存;Complete 只接受已托管的交易;Cancel 对已托管交易退款并退回物品。手续费在成交时从给卖方的货款中体现,取消时全额退给买方。

七、测试覆盖

包内测试覆盖五类场景:同一订单重放只扣一次款、余额不足不留下订单、托管失败不移动资产、成交后双方资产正确、撤销后全额退款且不能再次完成。重复状态迁移统一返回 ErrInvalidTradeState。

cd .local/checks/gs-practice-lab && gofmt -l ch09/economy && go vet ./ch09/economy/ && go test ./ch09/economy/ -count=1
ok  	gspractice/ch09/economy	0.xxx s

示例耗时因机器而异。本文与 /game-server-in-action-932/ 共用 economy 包,整包测试会同时验证货币、流水和交易。

小结

商城的可靠性来自服务端规则:价格服务端定、订单按业务单号幂等、限购服务端计数、上下架即时校验。玩家交易用托管状态机把双方资产先移入中间状态,全部成功后再结算,失败则原路退回。防欺诈要把交易锁、二次确认、异常价格检测和审计一起做,而不是只依赖界面提示。

常见错误与边界

  • 订单不幂等,支付回调重试就重复发货。
  • 先给货后扣钱,扣款失败时资产已经转移。
  • 限购按客户端本地计数,清缓存即可绕过。
  • 商品价格相信客户端传值,渠道折扣被篡改。
  • 交易完成状态还能再次结算,同一笔交易发两次货。
  • 托管只锁一方资产,另一方在结算前转移物品。
  • 异常价格只设固定上限,低价转移小号完全绕过。
  • 交易审计缺少设备与 IP,事后无法定位关联账号。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist