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 红点聚合与离线同步 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。