正文可以留下,附件必须先想清楚归属
《裂隙战场》赛季结束后,玩家会收到一封包含头像框、段位徽章和少量货币的邮件;《群岛纪元》的联盟补给邮件正文写的是“请及时领取”,附件里有木材、石料和加速道具;《泡泡工坊》的限时礼包邮件只有七天有效期。三款产品都把“通知”和“资产”放在同一封邮件里,但这两部分的生命周期完全不同。
正文是通知:发出去之后就不该变,玩家三天后回来看,内容仍然成立。附件是资产:它有归属人、有数量、有期限,领取要进入背包或货币账本,过期后必须有一个明确的去处。把附件当成正文的一个 JSON 字段,早期很省事,后期会遇到三个问题:领取无法单独加锁、过期扫描要扫描整张邮件表、背包满时无法判断“这封邮件到底算不算已处理”。
所以第一刀是把附件从正文拆开。邮件主表只存标题、正文、模板、发件时间和收件人;附件单独一张表,以邮件 ID 关联,每行一个物品堆,带自己的状态和到期时间。正文永远可读,附件按自己的状态机流转。
附件状态机
attached --claim--> claimed
attached --deadline reached--> expired
expired --grace passed-----> settled
attached --grace passed-----> settled
claimed / settled -----------> terminal
状态只有四个,但每个都有明确含义。attached 表示可领取;claimed 表示已经发放到背包或账本,重复领取必须返回同样的结果;expired 表示玩家已经失去领取权,但资产还没有完成结算;settled 表示结算任务已经处理完,资产离开邮件系统。把 expired 和 settled 分开,是为了让“玩家不能再领”和“后台已经处理”成为两个可观测的事实。
状态机里最关键的一条是:领取权在 ExpiresAt 截止,宽限期不延长领取权。很多团队把宽限期理解成“过期后还能再领十分钟”,结果客服、客户端和扫描任务对“到底能不能领”各有一套判断,最后出现玩家在过期后点领取,客户端显示成功、服务端返回失败,或者反过来。我们的约定是:now > ExpiresAt 就是过期,领取接口直接返回过期状态;宽限期只用于让扫描任务晚一点结算,避免和最后一秒的领取请求抢锁。
领取的原子性
领取一个附件,至少要做两件事:把附件从 attached 改成 claimed,把物品写进背包或货币账本。这两件事必须在同一个事务边界内,否则会出现两种脏状态:先加物品后改状态,进程崩溃后玩家可以再领一次;先改状态后加物品,崩溃后玩家既不能再领,也没有拿到东西。
| 方案 | 原子性 | 失败后 | 代价 | 适用 |
|---|---|---|---|---|
| 邮件库与背包同库同事务 | 强 | 整体回滚 | 要求同分片 | 单服角色资产 |
| 先落领取记录,再异步发货 | 最终一致 | 记录在,发货可重试 | 需要发货任务与对账 | 跨服务、跨分片 |
| 先发货,再改状态 | 弱 | 可能重复发货 | 依赖发货幂等键 | 不推荐 |
| 读时判断,不做状态 | 无 | 并发下重复领取 | 每次请求都要查历史 | 不推荐 |
《裂隙战场》和《泡泡工坊》的角色资产在同一个逻辑分片内,选同库同事务:附件的状态行、背包行、货币流水一起提交。《群岛纪元》的联盟补给可能跨服发放,选“先落领取记录,再异步发货”:领取记录以邮件 ID 为唯一键,发货任务按记录重试,背包侧再用同一个键做幂等。两种方案的共同点是领取记录必须先于或伴随发货产生,而不是等发货成功才补记。
一封邮件可能有多个物品堆。我们的语义是“整封邮件全有或全无”:先检查所有附件的归属、状态和背包空位,全部满足才一起改成 claimed;任何一个不满足,整封邮件保持 attached。这样玩家不会遇到“金币领到了、道具没领到”的半封状态,客服也不需要判断哪一半已经发过。
过期策略:删除、退回与补发
过期不是“把行删掉”这么简单。删除之前要回答:资产归谁、玩家能否感知、审计是否留痕。
| 策略 | 过期后资产归属 | 玩家体验 | 审计 | 适用 |
|---|---|---|---|---|
| 到期删除 | 系统回收,不再发放 | 邮件变灰,附件不可领 | 保留结算记录 | 低价值、限时活动 |
| 到期退回 | 退回到发件方或系统邮箱 | 可能收到一封新邮件 | 记录退回目标 | 联盟、公会集体资产 |
| 到期转补发 | 转为补偿邮件或货币 | 玩家仍能拿到,但形式变化 | 记录转换规则与批次 | 高价值、客诉敏感 |
| 到期保留 | 一直挂在邮件里 | 玩家可无限期领取 | 无 | 不建议,资产无法收敛 |
《泡泡工坊》的限时礼包选到期删除:活动结束就是结束,附件变灰,结算记录保留三十天备查。《群岛纪元》的联盟补给选到期退回:未领取的物资回到联盟仓库,而不是凭空消失,退回动作本身要记录来源邮件、目标仓库和数量。《裂隙战场》的赛季奖励选到期转补发:高价值奖励如果因为玩家短时间未登录而丢失,客诉成本远高于补发成本,但补发必须走独立的补偿批次,不能直接修改原邮件。
三种策略都要求同一个前提:过期扫描必须幂等。扫描任务可能重跑,可能多个实例同时跑,不能因为重复执行就重复结算。做法是按 ExpiresAt 建索引,每次取一批“已过宽限期且未结算”的记录,用状态条件更新抢占,只有更新成功的实例才能写结算记录。结算记录以附件 ID 为唯一键,重复写入直接忽略。
时区、边界与宽限期
到期时间存绝对时刻,通常是 UTC;展示时再按服务器时区或玩家时区转换。不要在数据库里存“10 月 30 日 23:59”这种本地时间字符串,否则跨时区、夏令时和合服都会带来歧义。
每日重置类邮件的到期时间要和重置边界对齐。如果重置是每天五点,邮件到期也设成五点,玩家在四点五十九分领取时会和重置任务竞争同一批数据。更稳妥的做法是把到期时间放在重置之后的低峰,例如五点三十分,并让扫描任务从六点开始。示例值:到期后给 15 分钟宽限期,扫描任务每 5 分钟跑一次,单批最多 1000 封。
宽限期的作用是吸收“领取请求已经在路上”的竞争,而不是给玩家额外时间。领取接口判断 now > ExpiresAt 就返回过期;扫描任务判断 now > ExpiresAt + grace 才结算。两个判断使用不同阈值,是这套设计里唯一允许的“双口径”,而且必须在接口文档和客服手册里写清楚。
背包满时怎么办
背包满不是一个可以静默处理的情况。常见做法有三种:
第一,拒收并保留邮件。领取接口返回“背包已满”,附件状态不变,玩家清理背包后可以再领。这是默认策略,简单、可解释,也不会让资产丢失。
第二,暂存到临时仓库。邮件系统不直接写背包,而是把物品放进一个“待领取暂存区”,玩家从暂存区再取。这个方案适合背包格子紧张的游戏,但会引入第二套资产容器,必须和背包一样有容量、过期和审计,否则只是把问题挪了个地方。
第三,自动分解或转成货币。只适用于明确可替换的低价值物品,而且必须在邮件正文里提前告知。高价值物品自动分解,几乎一定会引发客诉。
我们的选择是第一种:邮件本身就是暂存,不需要再造一个隐藏仓库。领取前先算整封邮件的总格数,空位不够就整封拒绝;不要先领一部分,也不要因为一个物品放不下就把整封邮件标记成已领取。背包满不是错误状态,而是一个可重试的业务结果。
流拍结算与资产归属
“流拍”原本是拍卖术语,在邮件系统里指“到期仍无人领取的附件”。它的结算要回答一个比技术更重要的问题:这份资产到底属于谁。
我们的规则是:未领取的附件在过期后不再属于玩家,也不属于其他玩家,而是回到系统或原发送方。系统回收要写结算流水,记录附件 ID、物品、数量、策略和时间;退回要给原发送方一封新邮件;转补发要生成新的补偿批次,并保留原邮件 ID 作为来源。任何情况下都不允许把 A 未领取的附件直接转给 B,也不允许在没有记录的情况下塞进 A 的背包。
这条规则听起来保守,但它让对账成为可能。玩家客诉“我没有收到赛季奖励”时,客服可以查到:邮件何时发出、附件何时过期、结算策略是什么、资产去了哪里。如果过期只是删行,这些问题都回答不了。
可运行片段:领取幂等与过期扫描
下面的 internal/mailattachment 把状态、存储和扫描拆成三个文件。文章嵌入 store.go 与 sweep.go;类型定义在 types.go,包括 State、Attachment、ClaimResult、Policy 和结算报告。
store.go 的核心是 Claim:先校验归属,再处理已领取的幂等重放,然后检查每个附件的状态和到期时间,最后检查背包空位。任何一步失败都不改状态;只有全部通过,才把所有附件一起改成 claimed,并把领取结果写进 claims 作为幂等记录。
// file: internal/mailattachment/store.go
// tier: L1
// verify: go test ./internal/mailattachment/
package mailattachment
import (
"sort"
"time"
)
type Store struct {
items map[string]*Attachment
byMail map[string][]string
claims map[string]ClaimResult
}
func New() *Store {
return &Store{
items: make(map[string]*Attachment),
byMail: make(map[string][]string),
claims: make(map[string]ClaimResult),
}
}
// Add inserts one attachment in the attached state.
func (s *Store) Add(a Attachment) error {
if a.ID == "" || a.MailID == "" || a.Recipient == "" || a.ItemID == "" {
return ErrEmptyID
}
if a.Count <= 0 || a.State != StateAttached {
return ErrInvalidCount
}
if _, ok := s.items[a.ID]; ok {
return ErrDuplicate
}
cp := a
s.items[cp.ID] = &cp
s.byMail[cp.MailID] = append(s.byMail[cp.MailID], cp.ID)
return nil
}
// Claim grants every attachment of one mail. Ownership, expiry and bag
// capacity are checked before any state changes, so a mail is all-or-nothing.
func (s *Store) Claim(mailID, recipient string, now time.Time, freeSlots int) (ClaimResult, error) {
ids := s.byMail[mailID]
if len(ids) == 0 {
return ClaimResult{MailID: mailID}, ErrNotFound
}
for _, id := range ids {
if s.items[id].Recipient != recipient {
return ClaimResult{MailID: mailID}, ErrNotOwner
}
}
first := s.items[ids[0]]
if first.State == StateClaimed {
if r, ok := s.claims[mailID]; ok {
r.Already = true
return r, nil
}
return ClaimResult{MailID: mailID, Already: true}, nil
}
if first.State == StateExpired || first.State == StateSettled {
return ClaimResult{MailID: mailID}, ErrExpired
}
total := 0
expired := false
for _, id := range ids {
a := s.items[id]
if a.State != StateAttached {
return ClaimResult{MailID: mailID}, ErrExpired
}
if now.After(a.ExpiresAt) {
expired = true
}
total += a.Count
}
if expired {
for _, id := range ids {
s.items[id].State = StateExpired
}
return ClaimResult{MailID: mailID}, ErrExpired
}
if total > freeSlots {
return ClaimResult{MailID: mailID}, ErrBagFull
}
result := ClaimResult{MailID: mailID, ClaimedAt: now}
for _, id := range ids {
a := s.items[id]
a.State = StateClaimed
result.ItemIDs = append(result.ItemIDs, a.ItemID)
}
sort.Strings(result.ItemIDs)
s.claims[mailID] = result
return result, nil
}
几个边界在代码里是显式的。第一,归属检查先于状态检查,避免通过错误信息探测别人的邮件。第二,已领取的邮件在过期后再次领取,仍然返回成功和同样的物品,因为“领取已经发生”这个事实不会因为时间过去而改变。第三,过期判断用 now.After(ExpiresAt),恰好等于到期时刻仍然可以领取;这样客户端和服务端不需要为“最后一毫秒”争论。第四,背包空位按整封邮件的总数量计算,不是按物品堆数量计算。
sweep.go 负责结算。它按附件 ID 排序后扫描,跳过已领取和已结算的记录,只处理超过 ExpiresAt + grace 的附件。结算报告返回扫描数、保留数和结算明细,调用方据此写流水、发退回邮件或生成补偿批次。
// file: internal/mailattachment/sweep.go
// tier: L1
// verify: go test ./internal/mailattachment/
package mailattachment
import (
"sort"
"time"
)
// Sweep settles unclaimed attachments whose grace period has passed. Grace
// protects an in-flight claim from racing the sweeper; it does not extend the
// player's claim right, which ends exactly at ExpiresAt.
func (s *Store) Sweep(now time.Time, grace time.Duration, policy Policy) SweepReport {
if grace < 0 {
grace = 0
}
report := SweepReport{Scanned: len(s.items)}
ids := make([]string, 0, len(s.items))
for id := range s.items {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
a := s.items[id]
if a.State == StateClaimed || a.State == StateSettled {
continue
}
if !now.After(a.ExpiresAt.Add(grace)) {
report.Kept++
continue
}
a.State = StateSettled
report.Settled = append(report.Settled, Settlement{
AttachmentID: a.ID,
MailID: a.MailID,
Recipient: a.Recipient,
ItemID: a.ItemID,
Count: a.Count,
Policy: policy,
At: now,
})
}
return report
}
Sweep 本身不决定资产去向,只决定“哪些附件该结算”和“采用什么策略”。这是有意的分层:扫描任务负责发现和抢占,结算动作由上层按策略执行。这样测试可以只验证状态机,不需要启动邮件服务和背包服务;生产上也可以把 PolicyReturn 的后续动作放进独立的重试队列。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 领取时先加物品后改状态 | 崩溃后可重复领取 | 同事务,或先落唯一领取记录 |
| 多物品只成功一部分 | 半封邮件已领取 | 整封邮件全有或全无,预检查全部附件 |
| 背包满被当成系统错误 | 玩家反复重试,日志噪音 | 返回可重试业务错误,保留附件 |
| 过期与领取并发 | 玩家显示成功、服务端已结算 | 领取权截止与扫描宽限期分离,状态条件更新 |
| 扫描任务重复执行 | 重复写结算流水 | 附件 ID 唯一键,条件更新抢占 |
| 时区存成本地时间 | 合服或夏令时后到期漂移 | 存 UTC 绝对时刻,展示层转换 |
| 过期直接删行 | 客诉无法对账 | 保留结算记录与来源邮件 ID |
| 未领取附件转给他人 | 资产归属错误,审计断链 | 只能回系统或原发送方,禁止跨玩家转移 |
本机验证
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/mailattachment
$ go vet ./internal/mailattachment/...
$ go test -count=1 ./internal/mailattachment/
ok gsarchlab/internal/mailattachment
本机退出码为 0,包内 6 个测试通过,其中 4 个是表驱动测试;gofmt -l 与 go vet 均无输出。测试覆盖到期边界、重复领取、整封邮件的背包检查、宽限期边界、已领取与已结算跳过,以及非法附件录入。
常见错误
第一,把附件塞进邮件正文的 JSON。领取、过期、结算都需要独立加锁和索引,塞进正文后只能整行更新,并发和扫描都会变复杂。
第二,宽限期当成领取延长期。领取权和扫描结算是两个阈值,混在一起会让客户端、服务端和客服各说各话。
第三,背包满时自动把邮件标记已领取。玩家既没拿到物品,也不能再领,是最难解释的资产丢失。
第四,过期直接删除记录。没有结算流水,客诉和对账都无从查起。
第五,把未领取附件转给其他玩家。这既不符合归属规则,也会让审计链断裂。
小结
附件是邮件系统里真正需要状态机的部分:正文负责通知,附件负责资产。领取要在归属、状态、到期和背包容量四个检查都通过后一次性完成;过期要把“失去领取权”和“完成结算”分开;宽限期只保护扫描竞争,不延长玩家权利;流拍资产只能回到系统或原发送方。下一节把单封邮件的领取扩展到批量:一键领取如何表达部分成功,补偿如何避免重复,红点又怎样和列表保持一致。
阅读导航:上一节:10.1 邮件投递与去重 · 下一节:10.3 批量领取、补偿与红点 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。