一、货币分层不是四个余额字段
玩家看到的“金币”“钻石”“绑定钻石”“活动币”,在服务端首先是四种权限不同的资产。它们的获取渠道、使用范围、能否交易、能否兑换成其他货币都不一样。把这些差异只做成四个余额字段,时间一长必然出现“免费货币被当作付费货币花掉”的漏洞。
货币模型要回答三个问题:这种货币从哪里来,能花到哪里去,能不能变成别的货币。三个答案都明确之后,余额字段只是结果,不是设计。
| 货币 | 典型来源 | 主要用途 | 交易 | 兑换边界 |
|---|---|---|---|---|
| 免费货币 | 任务、掉落、日常 | 修理、普通商店 | 可受限交易 | 只能单向流入付费或活动池,且有上限 |
| 付费货币 | 充值、退款补偿 | 商城、礼包 | 通常不可交易 | 不允许逆向换回免费货币 |
| 绑定货币 | 新手奖励、活动赠送 | 绑定商店、养成 | 不可交易 | 与自由货币隔离,不参与兑换 |
| 活动代币 | 限时活动 | 活动商店 | 不可交易 | 活动结束后按公告回收或折算 |
这张表要在策划、服务端、运营三处保持同一口径。任何一处私自放宽边界,都会变成刷取路径。
二、不可互换的边界必须写进代码
“付费货币不能换免费货币”不是一句运营规范,而是 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/ 会把视角从单个钱包拉到全服流水,讨论产出、消耗与存量如何统计。
小结
货币模型的核心不是余额字段,而是四种货币各自的来源、用途和兑换边界。方向表默认关闭,兑换带手续费和上下限,金额只用整数最小单位,乘除前检查溢出。做到这些,免费货币才不会顺着某个接口流进付费池。
常见错误与边界
- 用浮点数保存金额或汇率,结算误差随流水放大。
- 只在客户端限制兑换次数,服务端接口可以无限重放。
- 付费货币允许逆向换成免费货币,给退款和黑卡留下洗白通道。
- 绑定货币与自由货币共用余额,绑定物品被交易出去。
- 兑换没有单笔和周期上限,工作室可以批量搬运。
- 先扣源货币再校验目标货币溢出,失败后余额只改了一半。
- 手续费从源货币扣,玩家看到的到账金额与账单不一致。
- 兑换只记净额流水,审计无法还原方向和手续费。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。