《游戏服务端编程实践》9.3.1 货币模型与汇率

游戏经济系统的第一块地基是货币模型:免费、付费、绑定、活动代币各自能做什么,兑换方向与手续费如何限制,金额为什么必须用整数最小单位。本篇给出兑换边界、滑点与溢出的处理方式,并用 Go 实现带方向的 Wallet 与可运行测试,同时讨论跨币种结算和兑换上限这类常见漏洞。

一、货币分层不是四个余额字段

玩家看到的“金币”“钻石”“绑定钻石”“活动币”,在服务端首先是四种权限不同的资产。它们的获取渠道、使用范围、能否交易、能否兑换成其他货币都不一样。把这些差异只做成四个余额字段,时间一长必然出现“免费货币被当作付费货币花掉”的漏洞。

货币模型要回答三个问题:这种货币从哪里来,能花到哪里去,能不能变成别的货币。三个答案都明确之后,余额字段只是结果,不是设计。

货币典型来源主要用途交易兑换边界
免费货币任务、掉落、日常修理、普通商店可受限交易只能单向流入付费或活动池,且有上限
付费货币充值、退款补偿商城、礼包通常不可交易不允许逆向换回免费货币
绑定货币新手奖励、活动赠送绑定商店、养成不可交易与自由货币隔离,不参与兑换
活动代币限时活动活动商店不可交易活动结束后按公告回收或折算

这张表要在策划、服务端、运营三处保持同一口径。任何一处私自放宽边界,都会变成刷取路径。

二、不可互换的边界必须写进代码

“付费货币不能换免费货币”不是一句运营规范,而是 Wallet 必须拒绝的调用。只靠界面隐藏按钮不够:客户端可以重放请求,内部工具也可能直接调用兑换接口。

常见的边界有四条:

  • 付费货币永远不能逆向换成免费货币,否则等于给退款和黑卡提供了洗白通道。
  • 绑定货币与自由货币隔离,绑定货币购买得到的物品也应当绑定。
  • 活动代币只在活动期内有效,过期后不能静默变成通用货币。
  • 免费货币换付费货币只能是单向、限额、带手续费的促销行为,不能成为常态。

把这些规则做成显式的方向表,比在每个业务分支里写 if 更可靠。默认全部禁止,只有配置过的方向才允许,新增货币时不会因为忘记加判断而默认放开。

三、汇率、手续费与滑点

汇率不要用浮点数表示。0.1 + 0.2 这类误差在单次兑换里看不出来,放大到百万次流水就会对不上账。更稳妥的表示是整数分子和分母,例如 1:100 表示 1 个源货币换 100 个目标货币,结算时先乘后除。

手续费同样用基点表示,500 个基点就是 5%。手续费从换得的金额里扣,而不是从源货币里扣,这样玩家看到的“到账多少”和账单一致。

滑点来自整数除法的截断。按 1:3 兑换 101 个源货币,理论值是 33.67,服务端只能给 33,剩下的零头留在系统里。这个偏差必须被接受并记录,而不是用浮点数补足。规则要写清楚:截断方向固定为对玩家不利,避免有人构造金额让系统倒贴。

兑换还要有单笔上下限和周期上限。只有汇率没有上限,等于给工作室提供了一个可以无限搬运的通道。

四、金额口径:整数最小单位

所有金额在服务端、数据库、协议里都用整数最小单位:金币用“枚”,钻石用“分”或“厘”,不要用“元”。字段类型统一为有符号 64 位整数,接口参数禁止 float32、float64。

跨币种结算时,先确定记账币种,再在执行币种上完成扣减和入账。一笔兑换要产生两条流水:源货币扣减、目标货币增加,二者用同一个业务单号关联。只记一条“净额”会让审计无法还原方向,也无法发现手续费去了哪里。

乘除之前必须检查溢出。amount * numerator 在 amount 接近 math.MaxInt64 时会回绕成负数,结果是玩家余额不减反增。正确的顺序是先用 amount > math.MaxInt64/numerator 判断,再执行乘法。

五、失败场景:没有上限的兑换

某版本上线了“金币可按 1:1 换成钻石”的限时活动,只在前端做了每日一次的限制,服务端接口没有校验。工作室脚本直接循环调用兑换接口,把打金产出全部换成付费货币,再通过商城道具转手卖出。等到运营发现钻石消耗异常,已经过去了三天。

复盘时能看到三个问题叠加:兑换方向没有白名单,任何货币对都能尝试;单笔和周期上限只存在于客户端;兑换与发货不在同一个幂等键下,重放请求还能重复入账。修复不是把活动下线,而是把方向表、限额和业务单号一起补进服务端。

这类事故的共同点是“规则只写在文档和界面里”。只要服务端没有拒绝,规则就不存在。

六、Go 实现:带方向表的 Wallet

下面的 Wallet 把余额、兑换方向和手续费放在同一个结构里。Exchange 先完成全部校验,再一次性落账;任何一步失败,源货币和目标货币都不变。

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

package economy

import (
	"errors"
	"math"
)

// Currency 是货币种类。所有金额都以最小单位的整数表示,禁止浮点。
type Currency int

const (
	CurrencyFree Currency = iota
	CurrencyPaid
	CurrencyBound
	CurrencyEvent
)

var (
	ErrNegativeAmount      = errors.New("economy: amount must be positive")
	ErrInsufficientBalance = errors.New("economy: insufficient balance")
	ErrAmountOverflow      = errors.New("economy: amount overflow")
	ErrSameCurrency        = errors.New("economy: cannot exchange to the same currency")
	ErrInvalidRate         = errors.New("economy: invalid exchange rate")
	ErrExchangeLimit       = errors.New("economy: exchange amount out of range")
	ErrExchangeForbidden   = errors.New("economy: exchange direction forbidden")
	ErrInvalidEntry        = errors.New("economy: invalid ledger entry")
	ErrDuplicateEntry      = errors.New("economy: duplicate ledger entry")
	ErrEntryConflict       = errors.New("economy: duplicate entry id with different content")
	ErrInvalidWindow       = errors.New("economy: invalid time window")
)

// ExchangeRate 用整数分子分母表示汇率,避免浮点误差。
// FeeBPS 是手续费基点,10000 个基点等于 100%。
type ExchangeRate struct {
	Numerator   int64
	Denominator int64
	FeeBPS      int64
	MinAmount   int64
	MaxAmount   int64
}

// Wallet 保存同一玩家在各类货币上的余额,并限制允许的兑换方向。
type Wallet struct {
	balances map[Currency]int64
	policy   map[[2]Currency]bool
}

func NewWallet() *Wallet {
	return &Wallet{
		balances: make(map[Currency]int64),
		policy:   make(map[[2]Currency]bool),
	}
}

// AllowExchange 显式打开一条兑换边。默认所有方向都禁止。
func (w *Wallet) AllowExchange(from, to Currency) {
	w.policy[[2]Currency{from, to}] = true
}

func (w *Wallet) Balance(c Currency) int64 {
	return w.balances[c]
}

func (w *Wallet) Credit(c Currency, amount int64) error {
	if amount <= 0 {
		return ErrNegativeAmount
	}
	current := w.balances[c]
	if current > math.MaxInt64-amount {
		return ErrAmountOverflow
	}
	w.balances[c] = current + amount
	return nil
}

func (w *Wallet) Debit(c Currency, amount int64) error {
	if amount <= 0 {
		return ErrNegativeAmount
	}
	if w.balances[c] < amount {
		return ErrInsufficientBalance
	}
	w.balances[c] -= amount
	return nil
}

// Exchange 把 amount 个 from 货币换成 to 货币,返回实际到账金额。
// 所有校验都在落账前完成,失败时余额不变。
func (w *Wallet) Exchange(from, to Currency, amount int64, rate ExchangeRate) (int64, error) {
	if from == to {
		return 0, ErrSameCurrency
	}
	if amount <= 0 {
		return 0, ErrNegativeAmount
	}
	if !w.policy[[2]Currency{from, to}] {
		return 0, ErrExchangeForbidden
	}
	if rate.Numerator <= 0 || rate.Denominator <= 0 || rate.FeeBPS < 0 || rate.FeeBPS > 10000 {
		return 0, ErrInvalidRate
	}
	if rate.MinAmount < 0 || rate.MaxAmount <= 0 || rate.MinAmount > rate.MaxAmount {
		return 0, ErrInvalidRate
	}
	if amount < rate.MinAmount || amount > rate.MaxAmount {
		return 0, ErrExchangeLimit
	}
	if w.balances[from] < amount {
		return 0, ErrInsufficientBalance
	}
	if amount > math.MaxInt64/rate.Numerator {
		return 0, ErrAmountOverflow
	}
	gross := amount * rate.Numerator / rate.Denominator
	if gross <= 0 {
		return 0, ErrInvalidRate
	}
	// 拆分计算避免 gross*FeeBPS 溢出;余数部分最大只有 10^8。
	fee := gross/10000*rate.FeeBPS + (gross%10000)*rate.FeeBPS/10000
	net := gross - fee
	if net <= 0 {
		return 0, ErrInvalidRate
	}
	if w.balances[to] > math.MaxInt64-net {
		return 0, ErrAmountOverflow
	}
	w.balances[from] -= amount
	w.balances[to] += net
	return net, nil
}

AllowExchange 默认不打开任何方向,调用方必须显式配置。这比“默认允许、特殊情况禁止”安全,因为遗漏配置的后果是兑换失败,而不是兑换成功。

七、测试:手续费、截断、溢出一个都不少

测试覆盖四类行为:正常扣减与余额不足、加法溢出、带手续费的兑换、以及失败时余额不变。兑换测试同时断言源货币被扣光、目标货币按 475 到账,防止只改一边的实现漏过。

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

package economy

import (
	"errors"
	"math"
	"testing"
)

func TestCreditDebitAndOverflow(t *testing.T) {
	w := NewWallet()
	if err := w.Credit(CurrencyFree, 100); err != nil {
		t.Fatal(err)
	}
	if err := w.Debit(CurrencyFree, 30); err != nil {
		t.Fatal(err)
	}
	if got := w.Balance(CurrencyFree); got != 70 {
		t.Fatalf("balance = %d, want 70", got)
	}
	if err := w.Debit(CurrencyFree, 71); !errors.Is(err, ErrInsufficientBalance) {
		t.Fatalf("overspend err = %v", err)
	}
	if got := w.Balance(CurrencyFree); got != 70 {
		t.Fatalf("failed debit changed balance to %d", got)
	}
	if err := w.Credit(CurrencyPaid, math.MaxInt64); err != nil {
		t.Fatal(err)
	}
	if err := w.Credit(CurrencyPaid, 1); !errors.Is(err, ErrAmountOverflow) {
		t.Fatalf("overflow err = %v", err)
	}
}

func TestExchangeFeeAndRounding(t *testing.T) {
	w := NewWallet()
	w.AllowExchange(CurrencyFree, CurrencyPaid)
	w.AllowExchange(CurrencyBound, CurrencyFree)
	if err := w.Credit(CurrencyFree, 1000); err != nil {
		t.Fatal(err)
	}
	if err := w.Credit(CurrencyBound, 101); err != nil {
		t.Fatal(err)
	}
	rate := ExchangeRate{Numerator: 1, Denominator: 2, FeeBPS: 500, MaxAmount: 1000}
	got, err := w.Exchange(CurrencyFree, CurrencyPaid, 1000, rate)
	if err != nil {
		t.Fatal(err)
	}
	if got != 475 || w.Balance(CurrencyFree) != 0 || w.Balance(CurrencyPaid) != 475 {
		t.Fatalf("fee exchange: credited=%d free=%d paid=%d", got, w.Balance(CurrencyFree), w.Balance(CurrencyPaid))
	}
	rate = ExchangeRate{Numerator: 1, Denominator: 3, MaxAmount: 101}
	got, err = w.Exchange(CurrencyBound, CurrencyFree, 101, rate)
	if err != nil {
		t.Fatal(err)
	}
	if got != 33 {
		t.Fatalf("rounding = %d, want truncated 33", got)
	}
}

func TestExchangeFailuresAreAtomic(t *testing.T) {
	rate := ExchangeRate{Numerator: 1, Denominator: 1, FeeBPS: 100, MaxAmount: 100}
	cases := []struct {
		name     string
		prep     func(*Wallet)
		from, to Currency
		amt      int64
		want     error
	}{
		{"forbidden", func(w *Wallet) {}, CurrencyFree, CurrencyPaid, 10, ErrExchangeForbidden},
		{"same", func(w *Wallet) { w.AllowExchange(CurrencyFree, CurrencyFree) }, CurrencyFree, CurrencyFree, 10, ErrSameCurrency},
		{"insufficient", func(w *Wallet) { w.AllowExchange(CurrencyFree, CurrencyPaid) }, CurrencyFree, CurrencyPaid, 10, ErrInsufficientBalance},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			w := NewWallet()
			tc.prep(w)
			if _, err := w.Exchange(tc.from, tc.to, tc.amt, rate); !errors.Is(err, tc.want) {
				t.Fatalf("err = %v, want %v", err, tc.want)
			}
			if w.Balance(tc.from) != 0 || w.Balance(tc.to) != 0 {
				t.Fatal("failed exchange changed a balance")
			}
		})
	}
}

func TestExchangeLimitAndOverflow(t *testing.T) {
	w := NewWallet()
	w.AllowExchange(CurrencyFree, CurrencyPaid)
	if err := w.Credit(CurrencyFree, math.MaxInt64); err != nil {
		t.Fatal(err)
	}
	rate := ExchangeRate{Numerator: 3, Denominator: 1, MaxAmount: math.MaxInt64}
	if _, err := w.Exchange(CurrencyFree, CurrencyPaid, math.MaxInt64/2, rate); !errors.Is(err, ErrAmountOverflow) {
		t.Fatalf("err = %v, want amount overflow", err)
	}
	rate.MaxAmount = 5
	if _, err := w.Exchange(CurrencyFree, CurrencyPaid, 6, rate); !errors.Is(err, ErrExchangeLimit) {
		t.Fatalf("err = %v, want exchange limit", err)
	}
}

TestExchangeFailuresAreAtomic 用表驱动覆盖禁止方向、同币种和余额不足。每个子测试都检查两边余额仍为零,避免“先扣款后报错”的实现。

八、运行测试

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

示例输出中的耗时因机器而异,ok 表示编译与全部用例通过。gofmt -l 没有输出才说明格式一致。

下一篇 /game-server-in-action-932/ 会把视角从单个钱包拉到全服流水,讨论产出、消耗与存量如何统计。

小结

货币模型的核心不是余额字段,而是四种货币各自的来源、用途和兑换边界。方向表默认关闭,兑换带手续费和上下限,金额只用整数最小单位,乘除前检查溢出。做到这些,免费货币才不会顺着某个接口流进付费池。

常见错误与边界

  • 用浮点数保存金额或汇率,结算误差随流水放大。
  • 只在客户端限制兑换次数,服务端接口可以无限重放。
  • 付费货币允许逆向换成免费货币,给退款和黑卡留下洗白通道。
  • 绑定货币与自由货币共用余额,绑定物品被交易出去。
  • 兑换没有单笔和周期上限,工作室可以批量搬运。
  • 先扣源货币再校验目标货币溢出,失败后余额只改了一半。
  • 手续费从源货币扣,玩家看到的到账金额与账单不一致。
  • 兑换只记净额流水,审计无法还原方向和手续费。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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