一、商城的四条硬规则
商城不是“把商品列出来,点一下扣钱”。真正决定它是否可靠的是四条服务端规则:
| 规则 | 要解决的问题 | 错误做法 |
|---|---|---|
| 价格由服务端决定 | 客户端改价、渠道折扣不一致 | 相信客户端传来的金额 |
| 订单按业务单号幂等 | 超时重试、弱网重发导致重复发货 | 每次请求都新建订单 |
| 限购按服务端计数 | 客户端清缓存绕过限购 | 用本地存档记录购买次数 |
| 上下架即时生效 | 活动结束后仍能买到旧商品 | 只改前端配置,不校验服务端状态 |
渠道差异要落在“支付侧”而不是“商品侧”。同一个商品在 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,事后无法定位关联账号。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。