《游戏服务端架构·核心系统与契约》10.2 附件、过期与流拍结算

邮件正文可以永久可读,附件却是有归属、有期限的资产。《裂隙战场》的赛季奖励、《群岛纪元》的联盟补给、《泡泡工坊》的限时礼包,都要回答领取是否原子、过期后归谁、背包满时怎么办。本节给出附件状态机、过期扫描与宽限期、三种结算策略,并附可运行的领取幂等与流拍结算代码。

正文可以留下,附件必须先想清楚归属

《裂隙战场》赛季结束后,玩家会收到一封包含头像框、段位徽章和少量货币的邮件;《群岛纪元》的联盟补给邮件正文写的是“请及时领取”,附件里有木材、石料和加速道具;《泡泡工坊》的限时礼包邮件只有七天有效期。三款产品都把“通知”和“资产”放在同一封邮件里,但这两部分的生命周期完全不同。

正文是通知:发出去之后就不该变,玩家三天后回来看,内容仍然成立。附件是资产:它有归属人、有数量、有期限,领取要进入背包或货币账本,过期后必须有一个明确的去处。把附件当成正文的一个 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 批量领取、补偿与红点 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查