《游戏服务端架构·核心系统与契约》8.2 公会、权限与仓库

《群岛纪元》的公会仓库要同时面对会长、官员、成员三种角色,以及多人同时取物、权限变更和解散清算。本节把角色还原为权限位,比较硬编码角色、权限位和策略引擎三种方案,给出 Begin/Commit 票券式并发契约与可运行的仓库事务实现,并讨论解散和合并时资产如何不丢不重。

公会不是聊天群,是一份资产契约

《群岛纪元》的联盟仓库是全服玩家资产最集中的地方;《裂隙战场》的战队仓库只存外观和赛季奖励;《泡泡工坊》甚至没有公会。三款产品对“公会”的需求差了一个数量级,但都要回答三个问题:谁能取、同时取怎么办、解散后归谁。

最早的仓库实现把权限写成 if role == "leader",散落在十几个接口里。新增“官员可取但不可踢人”的规则时漏改了两个入口,官员能把最后一件资源取走。第二周又出现两人同时取最后一件,库存变成 -1。两个 bug 指向同一件事:角色和权限没有被当成契约,检查和提交之间也没有边界。

本节把公会拆成三层:角色到权限位的映射、仓库操作的乐观并发、解散时的资产清算。聊天频道留到下一节,因为它的扇出模型和仓库完全不是一类问题。

角色是权限预设,不是身份判断

角色存入取出邀请踢人解散
成员允许禁止禁止禁止禁止
官员允许允许允许仅成员禁止
会长允许允许允许官员与成员允许

最小权限原则在这里不是口号:成员只能存,官员能取和管理人,会长独享解散。取物权限单独一位,因为“能取”比“能邀请”危险得多;解散权限也单独一位,避免“管理员”这种大而全的角色被复制到所有入口。

角色是预设,不是散落的字符串比较。新增一种角色时,只改权限表,不改业务代码;要临时给某个人开权限,也应该走一次有审计的授权,而不是在代码里加 if playerID == ...。

三种权限模型

方案判断方式新增权限风险适用
硬编码角色每个接口写 role ==要改所有入口漏改即越权只有两三个接口的原型
权限位角色映射到位集合,一次位运算加一位、改映射位定义需评审大多数游戏公会
策略引擎外部规则表达式改配置延迟、审计、调试成本跨公会、按时间/额度

我们选权限位:角色是预设,判断是一次位运算,权限矩阵可以直接写成表驱动测试。策略引擎留到出现“按本周贡献度动态调整取物额度”这类规则时再引入;在那之前,它只会把一次函数调用变成一次难以复现的配置排查。

仓库并发:检查与提交之间的窗口

多人同时取物,最直接的做法是 Begin 读库存与角色,RPC 回来 Commit 写。中间可能发生三件事:库存被另一人取走、角色被降级、公会解散。若 Commit 只写不查,就会超卖、越权,或者从已经关闭的仓库里出库。

我们的契约是票券式乐观并发:Begin 返回 Ticket{Actor, Role, Epoch, Seen},Commit 重新校验角色、权限 epoch 和仓库版本。任一变化返回 ErrStale,客户端重新 Begin。锁不能跨网络持有,版本号把“检查”和“提交”之间的窗口显式化。

Begin ──► Ticket{role, epoch, version}
   │            │
   │            ├─ role changed ──► ErrStale
   │            ├─ vault moved  ──► ErrStale
   │            └─ ok ───────────► Commit: stock ± delta, version++
   └─ closed ─────────────────────► ErrClosed

为什么用整仓版本而不是按物品版本?公会仓库操作频率低、物品种类有限,整仓版本实现简单,冲突时重试一次即可。如果单个仓库有上千格且热点集中在少数物品,再把版本细化到 item,代价是内存和校验逻辑都会变复杂。

权限变更后的在途操作是这套契约的关键:SetRole 不只改角色,还给目标角色递增 Epoch。官员在 Begin 之后被降级,Commit 会发现 epoch 不匹配并拒绝,而不是拿旧权限继续取物。踢人则直接删除成员记录,Commit 返回 ErrNotMember。

可运行片段:权限位与票券提交

下面的 internal/guildvault 把角色映射、票券校验和仓库提交收在一个聚合里。踢人、转让会长和解散清算在同包的 settle.go;文章保留核心事务路径,避免把篇幅花在分支操作上。

// file: internal/guildvault/guildvault.go
// tier: L1
// verify: go test ./internal/guildvault/
package guildvault

import (
	"errors"
	"sync"
)

// Role is ordered from least to most privileged.
type Role int

const (
	RoleNone Role = iota
	RoleMember
	RoleOfficer
	RoleLeader
)

func (r Role) String() string {
	switch r {
	case RoleMember:
		return "member"
	case RoleOfficer:
		return "officer"
	case RoleLeader:
		return "leader"
	default:
		return "none"
	}
}

// Perm is a permission bit. Roles receive the smallest set they need: members
// may deposit, officers may also withdraw and manage people, the leader alone
// may disband.
type Perm uint32

const (
	PermDeposit Perm = 1 << iota
	PermWithdraw
	PermInvite
	PermKick
	PermDisband
)

var roleGrants = map[Role]Perm{
	RoleMember:  PermDeposit,
	RoleOfficer: PermDeposit | PermWithdraw | PermInvite | PermKick,
	RoleLeader:  PermDeposit | PermWithdraw | PermInvite | PermKick | PermDisband,
}

// Can reports whether the role holds every bit in p.
func (r Role) Can(p Perm) bool { return roleGrants[r]&p == p }

var (
	ErrEmpty      = errors.New("guildvault: empty guild id or role")
	ErrClosed     = errors.New("guildvault: guild is closed")
	ErrNotMember  = errors.New("guildvault: actor is not a member")
	ErrForbidden  = errors.New("guildvault: permission denied")
	ErrStale      = errors.New("guildvault: ticket is stale")
	ErrStock      = errors.New("guildvault: insufficient stock")
	ErrAmount     = errors.New("guildvault: amount must be non-zero")
	ErrItem       = errors.New("guildvault: empty item")
	ErrExists     = errors.New("guildvault: target is already a member")
	ErrLastLeader = errors.New("guildvault: cannot demote the leader")
)

// Ticket captures the permission epoch and vault version observed by Begin.
// Commit refuses the movement if either moved, so a role change or a competing
// vault operation cannot be applied against a stale decision.
type Ticket struct {
	Actor string
	Role  Role
	Epoch uint64
	Seen  uint64
}

// Guild is an in-memory aggregate. The mutex protects the maps; the ticket is
// the cross-RPC optimistic check, not a replacement for it.
type Guild struct {
	mu      sync.Mutex
	id      string
	closed  bool
	version uint64
	members map[string]Role
	epochs  map[string]uint64
	vault   map[string]int64
}

// New creates a guild whose only member is the leader.
func New(id, leader string) (*Guild, error) {
	if id == "" || leader == "" {
		return nil, ErrEmpty
	}
	return &Guild{
		id:      id,
		members: map[string]Role{leader: RoleLeader},
		epochs:  map[string]uint64{leader: 1},
		vault:   make(map[string]int64),
	}, nil
}

// Join adds a member. Officers and the leader may invite; the new member gets
// the least privileged role.
func (g *Guild) Join(actor, target string) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.closed {
		return ErrClosed
	}
	if !g.members[actor].Can(PermInvite) {
		return ErrForbidden
	}
	if target == "" {
		return ErrEmpty
	}
	if _, ok := g.members[target]; ok {
		return ErrExists
	}
	g.members[target] = RoleMember
	g.epochs[target] = 1
	return nil
}

// SetRole demotes or promotes an existing member. Only the leader may call it;
// changing the leader itself needs Transfer. Every change bumps the target's
// permission epoch, which invalidates tickets already handed out.
func (g *Guild) SetRole(actor, target string, role Role) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.closed {
		return ErrClosed
	}
	if g.members[actor] != RoleLeader {
		return ErrForbidden
	}
	current, ok := g.members[target]
	if !ok {
		return ErrNotMember
	}
	if current == RoleLeader {
		return ErrLastLeader
	}
	if role != RoleMember && role != RoleOfficer {
		return ErrForbidden
	}
	if current == role {
		return nil
	}
	g.members[target] = role
	g.epochs[target]++
	return nil
}

// Begin snapshots the caller's role, permission epoch and the vault version.
func (g *Guild) Begin(actor string) (Ticket, error) {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.closed {
		return Ticket{}, ErrClosed
	}
	role, ok := g.members[actor]
	if !ok {
		return Ticket{}, ErrNotMember
	}
	return Ticket{Actor: actor, Role: role, Epoch: g.epochs[actor], Seen: g.version}, nil
}

// Commit applies a signed movement: positive deposits, negative withdraws.
// It revalidates every fact the caller relied on before doing any work.
func (g *Guild) Commit(t Ticket, item string, delta int64) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.closed {
		return ErrClosed
	}
	if item == "" {
		return ErrItem
	}
	if delta == 0 {
		return ErrAmount
	}
	role, ok := g.members[t.Actor]
	if !ok {
		return ErrNotMember
	}
	if role != t.Role || g.epochs[t.Actor] != t.Epoch {
		return ErrStale
	}
	if g.version != t.Seen {
		return ErrStale
	}
	if delta > 0 && !role.Can(PermDeposit) {
		return ErrForbidden
	}
	if delta < 0 && !role.Can(PermWithdraw) {
		return ErrForbidden
	}
	next := g.vault[item] + delta
	if next < 0 {
		return ErrStock
	}
	if next == 0 {
		delete(g.vault, item)
	} else {
		g.vault[item] = next
	}
	g.version++
	return nil
}

// Stock returns the current amount of one item.
func (g *Guild) Stock(item string) int64 {
	g.mu.Lock()
	defer g.mu.Unlock()
	return g.vault[item]
}

// Snapshot returns a copy of the vault for reporting.
func (g *Guild) Snapshot() map[string]int64 {
	g.mu.Lock()
	defer g.mu.Unlock()
	out := make(map[string]int64, len(g.vault))
	for item, qty := range g.vault {
		out[item] = qty
	}
	return out
}

// Closed reports whether the guild has been disbanded.
func (g *Guild) Closed() bool {
	g.mu.Lock()
	defer g.mu.Unlock()
	return g.closed
}

Commit 的校验顺序是刻意的:先看公会是否关闭,再看物品与数量,然后确认成员仍在、角色和 epoch 未变、仓库版本未变,最后才检查权限位和库存。任何一步失败都不修改库存,也不递增版本,调用方可以安全重试。

Join 只有官员和会长能调用,新成员固定是最低权限;SetRole 只有会长能调用,且不能直接改动会长本人,转移会长必须走 Transfer。这两条限制让“公会无主”和“两个会长”都不会通过正常接口出现。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/guildvault
$ go vet ./internal/guildvault/...
$ go test -count=1 ./internal/guildvault/
ok  	gsarchlab/internal/guildvault

本机 go test 退出码为 0,5 个表驱动测试通过;gofmt -l 与 go vet 均无输出。

失败模式与兜底

失败表现兜底
检查与提交之间被降级越权取出权限 epoch,Commit 返回 ErrStale
两人同时取最后一件库存为负整仓版本加非负校验,冲突重试
解散时仍有在途取物解散后仍出库先置 closed,Commit 返回 ErrClosed
会长被误降级公会无主SetRole 拒绝 RoleLeader,转移走 Transfer
官员踢同级内斗、越权严格 rank 比较,只能踢更低角色
清算余数丢弃玩家资产缩水余数给会长,按物品逐项结算
快照被外部修改报表与实物不符Snapshot 返回副本,写路径只经 Commit

解散、合并与资产清算

解散不是删一行公会记录。Disband 先把 closed 置位并递增版本,让所有在途 Commit 立即失败;然后按成员数均分每件物品,余数给会长,返回完整的 Payout 列表。结算必须一次完成:要么所有物品都有归属,要么整个解散回滚。实际落地时,Payout 列表会带一个幂等键写入资产服务,避免重试导致重复发放。

合并比解散多两个约束:双方会长都要确认,且成员、仓库、名称标签不能出现半合并状态。做法是先把两个公会标记为 merging 并冻结写入,再在一个事务里迁移成员与资产,最后切换公会 ID。冻结期间任何 Begin 都返回 ErrClosed 类的错误,客户端显示“合并中”,而不是让玩家反复重试。

跨服公会不共享一张实时表。公会归属某个逻辑服,其他服只保留成员列表和仓库的只读投影;取物请求回到归属服执行,投影延迟最多一个同步周期(示例值)。这样仓库事务仍然是单点串行,不需要跨服分布式锁。

常见错误

第一,把角色判断散落在业务代码里。新增权限时靠全局搜索,漏掉一个入口就是线上越权。

第二,只校验角色,不校验变化。Begin 时是官员,Commit 时已经被降级;没有 epoch 就会拿旧身份完成操作。

第三,用互斥锁跨 RPC 持有。锁在进程内,RPC 超时后无法安全释放;应该用版本号和幂等提交。

第四,解散时先删成员再算资产。成员列表没了,均分基数就错了;必须先冻结、再按快照结算。

第五,合并期间允许取物。两个仓库的资产还没合并完,任何一笔出库都会让对账永远对不上。

小结

公会的核心是把“角色”降级为“权限预设”,把“仓库操作”做成带票券的乐观事务。角色变更递增 epoch,库存变更递增版本,解散先关闭再清算;三条规则覆盖了越权、超卖和资产丢失。

公会解决了几十人的协作与资产。下一节处理更大的扇出面:世界、战区、公会和私聊频道如何隔离,刷屏如何在跨频道时仍被拦住,审核又该放在发送前还是发送后。

阅读导航:上一节:8.1 好友、黑名单与关系一致性 · 下一节:8.3 聊天频道、审核与限速 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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