《MMO 服务器开发》15.2 附件、过期与流拍结算

邮件带上附件之后,「领取」就不再是读一下就完事,而是一次必须恰好一次的资产发放。本节给 aegis 补上附件领取、过期扫描与流拍结算:为什么领取要按槽位记账、过期的未领附件为什么不能凭空消失,以及流拍返还的物品最终怎么折算成绑定货币,附真跑通过的 L1 测试。

15.2 附件、过期与流拍结算

15.1 的邮箱只会「投递」。一旦信里带了附件,「领取」就不再是读一下就完事——它是一次资产发放,必须恰好一次:重复领就是刷道具,漏领就是吞玩家奖励。而且邮件是会过期的,过期时那件还没被领走的附件去哪,必须有一条明确、可复现的规则。

现实里最麻烦的是拍卖流拍。拍卖行在标期结束时把「没卖出去的物品」通过邮件返还给卖家;如果卖家一直不上线、返还邮件过期了,这件物品既不能永远卡在一封死信里,也不能凭空消失。这里的处理叫流拍结算:把物品按规则折算成绑定货币发到背包,玩家不至于一无所有,经济上也不产生可交易的新增。

这一节把 15.1 的邮箱补全三块:按槽位记账的领取、过期扫描、以及流拍结算。代码仍是 L1,go test 直接能跑。

领取是一次资产发放,不是读邮件

最省事的实现是给邮件一个 Claimed bool,领过就置真。但玩家往往只想领其中一件——背包满了、或者只想要金币不想拿占格子的道具。一个 bool 逼着玩家要么全领、要么全不领,很别扭。

所以领取要按槽位记账:stateLocked 给每封邮件返回一个和附件列表等长的 []bool,第 i 位表示第 i 件附件是否已经发放过。Claim(id, idx) 领一件,ClaimAll(id, now) 一次领掉所有还没领的。幂等就落在这个槽位位上:同一对 (id, idx) 第二次进来直接返回 ErrAlreadyClaimed,绝不会重复发放。

这些标记刻意不放在 Mail 值里,而是存在邮箱的 claimed 表里。原因是 15.1 的 List 返回的是 Mail 的拷贝快照;如果标记跟着值走,一份旧快照被 Claim 改过之后会和真实状态对不上。把它放在邮箱里,快照永远是只读的。

还有一条保护:Discard 只允许丢弃已领完的邮件。否则「满就删最旧」或者玩家误点,都可能把一件还没领的附件连信一起丢掉。

下面这段是 L1,go test 可以直接跑:

// file: internal/mail/claim.go
// tier: L1
// verify: go test ./internal/mail/

package mail

import (
	"errors"
	"time"
)

// ErrAlreadyClaimed is returned when a claim targets an attachment that has
// already been granted.
var ErrAlreadyClaimed = errors.New("mail: attachment already claimed")

// ErrMailExpired is returned when a claim targets a mail past its ExpireAt.
var ErrMailExpired = errors.New("mail: mail expired")

// ErrNoAttachment is returned when the attachment index is out of range.
var ErrNoAttachment = errors.New("mail: no such attachment")

// Grant is the outcome of a claim or an expiry sweep: what the bag service must
// add for the player. Bound grants are non-tradeable (auction settlement).
type Grant struct {
	Player PlayerID
	Att    Attachment
	Bound  bool
}

// stateLocked returns the per-attachment claim flags for id, sized to n. It
// lives in the mailbox rather than the Mail value so a List snapshot cannot be
// mutated by a later claim.
func (mb *Mailbox) stateLocked(id MailID, n int) []bool {
	if mb.claimed == nil {
		mb.claimed = make(map[MailID][]bool)
	}
	s, ok := mb.claimed[id]
	if !ok || len(s) != n {
		s = make([]bool, n)
		mb.claimed[id] = s
	}
	return s
}

// Claim grants attachment idx of mail id exactly once. A repeat call for the
// same (id, idx) returns ErrAlreadyClaimed; the mail is left in place so the
// player can still read the body and claim the rest.
func (mb *Mailbox) Claim(id MailID, idx int, now time.Time) (Grant, error) {
	mb.mu.Lock()
	defer mb.mu.Unlock()
	m, ok := mb.mails[id]
	if !ok {
		return Grant{}, ErrNoSuchMail
	}
	if !m.ExpireAt.IsZero() && !now.Before(m.ExpireAt) {
		return Grant{}, ErrMailExpired
	}
	if idx < 0 || idx >= len(m.Attachments) {
		return Grant{}, ErrNoAttachment
	}
	flags := mb.stateLocked(id, len(m.Attachments))
	if flags[idx] {
		return Grant{}, ErrAlreadyClaimed
	}
	flags[idx] = true
	if allTrue(flags) {
		m.Claimed = true
	}
	return Grant{Player: mb.owner, Att: m.Attachments[idx]}, nil
}

// ClaimAll grants every still-unclaimed attachment in one call and marks the
// mail claimed. Already-claimed slots are skipped, so the call is idempotent at
// the mail level: a second ClaimAll returns no grants and no error.
func (mb *Mailbox) ClaimAll(id MailID, now time.Time) ([]Grant, error) {
	mb.mu.Lock()
	defer mb.mu.Unlock()
	m, ok := mb.mails[id]
	if !ok {
		return nil, ErrNoSuchMail
	}
	if !m.ExpireAt.IsZero() && !now.Before(m.ExpireAt) {
		return nil, ErrMailExpired
	}
	flags := mb.stateLocked(id, len(m.Attachments))
	var out []Grant
	for i, a := range m.Attachments {
		if flags[i] {
			continue
		}
		flags[i] = true
		out = append(out, Grant{Player: mb.owner, Att: a})
	}
	m.Claimed = true
	return out, nil
}

// Discard deletes a mail the player no longer wants. Only a fully claimed mail
// may be discarded, so an unclaimed attachment can never be silently dropped.
func (mb *Mailbox) Discard(id MailID) error {
	mb.mu.Lock()
	defer mb.mu.Unlock()
	m, ok := mb.mails[id]
	if !ok {
		return ErrNoSuchMail
	}
	if !m.Claimed {
		return ErrAlreadyClaimed
	}
	mb.remove(id)
	return nil
}

func allTrue(b []bool) bool {
	for _, v := range b {
		if !v {
			return false
		}
	}
	return true
}

注意 Claim 在放行前先判了过期:!now.Before(m.ExpireAt) 就返回 ErrMailExpired。过期判定用的是传入的 now,不是内部 time.Now()——和第 11 章一样,这让测试能把时钟拨到过期之后,而不用真的等三天。

过期扫描:不能让附件烂在死信里

过期处理是一个扫描函数 Sweep(now, conv):把所有 ExpireAt <= now 的邮件挑出来,先跳过已经领过的槽位,再把还没领的附件转成 Grant——也就是「背包服务该给玩家加什么」。做完之后邮件才被删掉,它的 ID 继续留在 dedup 里,所以过期的邮件不会被重复投递。

Grant 里有个 Bound 字段,用来区分「可交易的物品」和「绑定的货币」。普通邮件过期,未领附件原样进背包,Bound=false;拍卖流拍邮件过期,则走下面这条折算路径。扫描本身很短:

// snippet: internal/mail/expire.go

// Sweep 遍历邮箱,过期所有 ExpireAt <= now 的邮件。玩家没领的附件不会丢:
// KindNormal 邮件把原始物品交给背包,KindAuctionReturn 邮件把物品过一遍 conv
// 并返回一个绑定 Grant。随后邮件被删除,其 ID 仍留在去重集里。
func (mb *Mailbox) Sweep(now time.Time, conv func(Attachment) Attachment) []Grant {
	mb.mu.Lock()
	defer mb.mu.Unlock()
	var out []Grant
	// 先复制 order:循环里会调用 remove,直接遍历会被边删边走搞乱索引。
	for _, id := range append([]MailID(nil), mb.order...) {
		m := mb.mails[id]
		if m.ExpireAt.IsZero() || now.Before(m.ExpireAt) {
			continue
		}
		flags := mb.stateLocked(id, len(m.Attachments))
		for i, a := range m.Attachments {
			if flags[i] {
				continue
			}
			if m.Kind == KindAuctionReturn && conv != nil {
				out = append(out, Grant{Player: mb.owner, Att: conv(a), Bound: true})
			} else {
				out = append(out, Grant{Player: mb.owner, Att: a})
			}
		}
		mb.remove(id)
	}
	return out
}

这段代码里有个容易被忽略的细节:for _, id := range append([]MailID(nil), mb.order...)。Sweep 会在循环里调用 remove,而 remove 会改 order 这个切片;直接遍历 mb.order 一边删一边走,索引会错乱。先复制一份再遍历,是这类「遍历中删除」场景的标准写法。

流拍结算:物品 → 绑定货币

拍卖流拍返还的邮件,Kind 标成 KindAuctionReturn。它和普通邮件的区别只在过期那一刻:普通邮件把物品原样退回背包,流拍邮件则把物品折算成绑定货币——conv 就是那个折算函数,输入一件物品、输出一笔货币。折出来的 Grant 带 Bound=true,意味着这笔钱不可交易,堵死「流拍返还 → 变卖套利」的口子。

两条纪律必须写进实现:

  • conv 必须是纯函数且只被调用一次。Sweep 删掉邮件后就再也不会为它折算第二次,所以折算结果一旦下发就必须落库,不能依赖「下次扫描再算一遍」。
  • 折算率来自配置,不在代码里硬编码。代码只管「调用 conv」,业务数值由掉落/经济配置决定。

测试同时钉住了普通过期与流拍结算两条路径:

// snippet: internal/mail/claim_test.go(节选)

func TestSweepSettlesFailedAuctionAsBound(t *testing.T) {
	mb := NewMailbox(7, 16, time.Hour)
	m := withAttach(5, 9001)
	m.Kind = KindAuctionReturn
	m.ExpireAt = base.Add(time.Hour)
	_ = mb.Deliver(m, base)

	// 把流拍物品折算成绑定货币:1 件 -> 50 金币。
	gold := func(a Attachment) Attachment { return Attachment{ItemID: 1, Count: a.Count * 50} }
	grants := mb.Sweep(base.Add(2*time.Hour), gold)
	if len(grants) != 1 || !grants[0].Bound {
		t.Fatalf("settlement = %+v, want one bound grant", grants)
	}
}

接线到命令上,附件领取走 CmdMailClaimReq 0x0A02:请求带邮件 id 和附件下标,响应把 Grant 交回给背包服务;而 Sweep 不挂在客户端命令上,它由服务端定时任务驱动(每日重置后扫一遍,见第 10 章)。

本节小结

  • 领取按槽位记账,不是邮件级 bool;(id, idx) 恰好发放一次,重复领取返回 ErrAlreadyClaimed。
  • 槽位标记放在邮箱而非 Mail 值里,让 List 快照保持只读,避免旧快照被后续领取改脏。
  • 过期扫描把未领附件转成 Grant 后才删邮件,ID 留在去重集里;遍历前先复制 order,避开「遍历中删除」。
  • 流拍结算用纯函数 conv 把物品折算成 Bound 货币,只算一次、折算率来自配置,堵住套利。

阅读导航:上一节:15.1 邮件投递与去重 · 下一节:15.3 红点聚合与离线同步 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标