公会不是聊天群,是一份资产契约
《群岛纪元》的联盟仓库是全服玩家资产最集中的地方;《裂隙战场》的战队仓库只存外观和赛季奖励;《泡泡工坊》甚至没有公会。三款产品对“公会”的需求差了一个数量级,但都要回答三个问题:谁能取、同时取怎么办、解散后归谁。
最早的仓库实现把权限写成 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 聊天频道、审核与限速 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。