13.1 入会、职位与权限
第 12 章的队伍是临时的:满员即锁、掉线即走、打完即散,它的整个生命周期都在一个副本里。公会不一样。公会是 MMO 里第一个跨玩家的持久组织——它跨登录、跨地图、跨副本,成员今天加入可能三个月后还在,会长离线一周公会也照常运转。这种「持久 + 有层级 + 多人可操作」的组合,是玩法系统里第一处真正需要认真对待权限的地方。
难在哪?难在权限不是一个布尔值,而是「职位 × 动作 × 对象」的乘积。会长能踢人,官员能不能?官员能不能踢副会长?副会长能不能把别人提成会长?入会也一样:自由加入的公会点一下就在名单里,审批制的公会要先排队、再由有权限的人放行,而放行的瞬间公会可能已经满了。这些规则如果散落在几十个 if 里,每加一个职位就得全仓搜一遍,漏一处就是一个越权漏洞。
本节的做法是把复杂度收敛到两个地方:权限收敛成一张「职位 → 动作位掩码」的表,读表即知谁能在哪;入会收敛成「策略分派 + 幂等」两个入口,把满员竞态和重复申请在写名单之前挡掉。职位变更则用「不能碰大于等于自己等级的职位」这一条铁律封住越权。下面的代码是 L1,go test 直接能跑。
CmdGuildJoinReq 0x0801 客户端申请入会 / 审批请求
CmdGuildInfoSync 0x0802 服务端下发公会快照(成员、职位、仓库、战况)
GuildID 是全服唯一的 uint64,PlayerID 也是 uint64;这两个类型在本章 internal/guild/ 包内自定义一份最小版本,与卷二其它章自包含。
与站内实战文章的分工
站内 game/server/practice/ 里有一篇公会后端设计的架构文章。它负责「线上怎么设计、怎么取舍」,本节负责「从零写出可运行的那一层」:
| 站内既有文章 | 它负责的问题 | 本节负责的部分 |
|---|---|---|
/game-server-guild-system-backend-design/ | 公会后端整体架构、存储选型、跨服公会与容量规划 | 单服内从零实现:职位/权限表、入会幂等、审批时重查容量 |
一句话:要看架构取舍去读那篇,要一份能 go test 通过的职位与入会实现读本节。
职位与权限:一张表定死
权限模型最容易写坏的方式,是把判断写进业务分支:if m.Rank == RankLeader || m.Rank == RankViceLeader { ... }。这样写能跑,但两周后没人说得清「官员到底能不能改公告」。我们改成一张表:Rank 是低到高的有序枚举,Permission 是位掩码,rankPerms 把「哪个职位有哪些权限」一次说清,业务只调 Can。
下面这段是 L1 的核心(摘自 internal/guild/model.go),go test 可以直接跑:
// snippet: internal/guild/model.go(职位与权限部分)
// Rank is a guild member position, ordered from low to high. The numeric order
// is load-bearing: the rules in guild.go compare ranks directly.
type Rank uint8
const (
RankMember Rank = iota // 普通成员
RankElite // 精英
RankOfficer // 官员
RankViceLeader // 副会长
RankLeader // 会长
)
// Permission is a bitmask of actions a rank may perform.
type Permission uint32
const (
PermInvite Permission = 1 << iota
PermKick
PermEditNotice
PermStorageDeposit
PermStorageWithdraw
PermWarSignup
PermPromote
PermDisband
)
// rankPerms is the single source of truth for "which rank may do what". Keeping
// it in one table instead of scattering checks across call sites is what makes
// the permission model auditable: a reviewer reads one map, not forty branches.
var rankPerms = map[Rank]Permission{
RankMember: PermStorageDeposit,
RankElite: PermStorageDeposit,
RankOfficer: PermStorageDeposit | PermInvite | PermStorageWithdraw | PermEditNotice,
RankViceLeader: PermStorageDeposit | PermInvite | PermStorageWithdraw | PermEditNotice | PermKick | PermPromote | PermWarSignup,
RankLeader: PermStorageDeposit | PermInvite | PermStorageWithdraw | PermEditNotice | PermKick | PermPromote | PermWarSignup | PermDisband,
}
// Can reports whether the rank holds perm.
func (r Rank) Can(perm Permission) bool { return rankPerms[r]&perm != 0 }
三个设计点。第一,职位是有序枚举,RankMember 是 0、RankLeader 是 4。后面所有「不能踢比自己高的人」「不能提别人到自己等级」的判断,都是拿这个序号直接比大小——不需要再写一张「谁比谁大」的邻接表,序号本身就是层级。第二,权限用位掩码而不是权限列表:Can 是一次按位与,O(1)、无分配、可内联;一个成员的总权限就是一个 uint32,可以随 CmdGuildInfoSync 一起下发,客户端不用自己算。第三,越高职位是越低职位的超集:从表里能一眼看出会长拥有官员的全部权限再加两项。这不是巧合,是刻意的——权限若做成互不相交的集合,运营配错一次就会出现「会长改不了公告」这种荒唐 bug。
入会:策略分派与幂等
入会只有两个入口:自由加入与申请审批。我们把它们合并到一个 RequestJoin,按 Policy 分派,返回值里 joined 说明「这次是不是直接进了名单」。关键在于两条路径都必须幂等:已经在名单里的人再点一次入会、申请还在队列里的人再点一次申请,都不能产生第二条记录。
下面这段是 L1,go test 可以直接跑:
// file: internal/guild/guild.go
// tier: L1
// verify: go test ./internal/guild/ -run 'TestJoin|TestKick|TestRank|TestLeader' -v
package guild
import (
"sort"
"time"
)
// Guild holds the roster and the admission state of one guild. Every method is
// called from the guild's own single-threaded logic goroutine, so there is no
// mutex here on purpose: the locking discipline is "one guild, one executor",
// the same ownership rule as 卷二第 1 章.
type Guild struct {
ID GuildID
Name string
Leader PlayerID
Policy JoinPolicy
Capacity int
Members map[PlayerID]*Member
Pending map[PlayerID]time.Time // 待审批申请:玩家 -> 申请时间
}
// NewGuild creates a guild whose founder is the first leader.
func NewGuild(id GuildID, name string, leader PlayerID, leaderName string, policy JoinPolicy, capacity int, now time.Time) *Guild {
if capacity <= 0 {
capacity = 50
}
g := &Guild{
ID: id,
Name: name,
Leader: leader,
Policy: policy,
Capacity: capacity,
Members: make(map[PlayerID]*Member),
Pending: make(map[PlayerID]time.Time),
}
g.Members[leader] = &Member{PlayerID: leader, Name: leaderName, Rank: RankLeader, JoinedAt: now}
return g
}
// can is the internal permission gate; it also rejects non-members.
func (g *Guild) can(actor PlayerID, perm Permission) bool {
m, ok := g.Members[actor]
if !ok {
return false
}
return m.Rank.Can(perm)
}
// RequestJoin dispatches on policy: an open guild admits immediately and reports
// joined=true; an apply-only guild queues the request and reports joined=false.
// Both paths are idempotent — a duplicate never creates a second roster entry or
// a second pending row.
func (g *Guild) RequestJoin(p PlayerID, name string, now time.Time) (joined bool, err error) {
if _, ok := g.Members[p]; ok {
return false, ErrAlreadyMember
}
if len(g.Members) >= g.Capacity {
return false, ErrGuildFull
}
if g.Policy == JoinOpen {
g.Members[p] = &Member{PlayerID: p, Name: name, Rank: RankMember, JoinedAt: now}
return true, nil
}
if _, ok := g.Pending[p]; ok {
return false, ErrAlreadyApplied
}
g.Pending[p] = now
return false, nil
}
// Approve admits a pending applicant. The actor must hold PermInvite, and the
// room is re-checked here because members may have filled the guild while the
// application sat in the queue.
func (g *Guild) Approve(actor, applicant PlayerID, name string, now time.Time) error {
if !g.can(actor, PermInvite) {
return ErrPermissionDenied
}
if _, ok := g.Pending[applicant]; !ok {
return ErrNoApplication
}
if len(g.Members) >= g.Capacity {
return ErrGuildFull
}
delete(g.Pending, applicant)
g.Members[applicant] = &Member{PlayerID: applicant, Name: name, Rank: RankMember, JoinedAt: now}
return nil
}
// Leave removes a member. The leader may not leave while others remain, or the
// guild would be headless — transferring leadership is a separate operation.
func (g *Guild) Leave(p PlayerID) error {
m, ok := g.Members[p]
if !ok {
return ErrNotMember
}
if m.Rank == RankLeader && len(g.Members) > 1 {
return ErrHeadlessGuild
}
delete(g.Members, p)
return nil
}
// Kick removes target. The actor needs PermKick and may not remove a rank at or
// above their own — a vice-leader cannot kick a peer or the leader.
func (g *Guild) Kick(actor, target PlayerID) error {
if !g.can(actor, PermKick) {
return ErrPermissionDenied
}
t, ok := g.Members[target]
if !ok {
return ErrNotMember
}
if t.Rank >= g.Members[actor].Rank {
return ErrBadRankChange
}
delete(g.Members, target)
return nil
}
// SetRank changes target's rank. Two rules keep the hierarchy sane: the actor may
// not touch a rank at or above their own, and nobody may mint a second leader.
func (g *Guild) SetRank(actor, target PlayerID, r Rank) error {
if !g.can(actor, PermPromote) {
return ErrPermissionDenied
}
a, ok := g.Members[actor]
if !ok {
return ErrNotMember
}
t, ok := g.Members[target]
if !ok {
return ErrNotMember
}
if target == actor {
return ErrBadRankChange
}
if t.Rank >= a.Rank || r >= a.Rank {
return ErrBadRankChange
}
t.Rank = r
return nil
}
// Roster returns members sorted by rank desc, then join time asc, then id. The
// order is fully deterministic so the client can render it without re-sorting,
// and 13.3 relies on it to snapshot a stable war roster.
func (g *Guild) Roster() []*Member {
out := make([]*Member, 0, len(g.Members))
for _, m := range g.Members {
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Rank != out[j].Rank {
return out[i].Rank > out[j].Rank
}
if !out[i].JoinedAt.Equal(out[j].JoinedAt) {
return out[i].JoinedAt.Before(out[j].JoinedAt)
}
return out[i].PlayerID < out[j].PlayerID
})
return out
}
四个必须点破的细节。第一,Guild 故意没有互斥锁。这不是省事,而是延续卷二第 1 章的状态归属:一个公会绑定一个逻辑执行体,所有变更都在那一个 goroutine 里串行跑,于是「检查容量」和「写入名单」之间不可能被别的协程插进来。真正的满员竞态,是跨请求的:甲申请时还有空位,乙先被批准把位置占了,等甲被审批时已经满了——所以 Approve 里再查一次 Capacity,这就是那第二道门。第二,Kick 和 SetRank 共用 t.Rank >= a.Rank 这条判据:动不了大于等于自己的职位,一个副会长既踢不掉同级,也踢不掉会长,更提不出第二个会长。第三,Leave 里会长不能裸退——公会的 Leader 字段必须始终指向一个在册成员,否则「谁有权解散公会」就成了悬空问题;转让会长是独立操作,本节不展开。第四,Roster 的排序是全确定的:职位降序、入会时间升序、ID 兜底,客户端拿来即渲染;13.3 冻结公会战名单时会直接复用这个顺序,保证同一份名单每次快照逐字节一致。
真跑与实测结论
测试不 mock 任何东西,直接构造公会喂进去。三条边界各一个用例:
// snippet: internal/guild/guild_test.go(节选)
func TestJoinOpenRespectsCapacity(t *testing.T) {
g := NewGuild(1, "晨曦骑士团", 100, "阿澈", JoinOpen, 3, base)
for _, id := range []PlayerID{101, 102} {
joined, err := g.RequestJoin(id, "p", base)
if err != nil || !joined {
t.Fatalf("join %d: joined=%v err=%v", id, joined, err)
}
}
if _, err := g.RequestJoin(103, "p", base); !errors.Is(err, ErrGuildFull) {
t.Fatalf("want ErrGuildFull, got %v", err)
}
}
func TestApplyDedup(t *testing.T) {
g := NewGuild(1, "g", 100, "leader", JoinApply, 10, base)
if joined, err := g.RequestJoin(200, "p", base); err != nil || joined {
t.Fatalf("first apply: joined=%v err=%v", joined, err)
}
if _, err := g.RequestJoin(200, "p", base); !errors.Is(err, ErrAlreadyApplied) {
t.Fatalf("want ErrAlreadyApplied, got %v", err)
}
if len(g.Pending) != 1 {
t.Fatalf("pending = %d, want 1", len(g.Pending))
}
if err := g.Approve(100, 200, "p", base); err != nil {
t.Fatalf("approve: %v", err)
}
if _, err := g.RequestJoin(200, "p", base); !errors.Is(err, ErrAlreadyMember) {
t.Fatalf("rejoin after approval: want ErrAlreadyMember, got %v", err)
}
}
本机(Go 1.26.0,macOS arm64)实测,go test ./internal/guild/ 全绿 13 个用例、go vet 无告警。同机相对量级,绝对数值不可移植:
| 断言 | 结果 | 说明 |
|---|---|---|
| 自由加入的第 3 人 | ErrGuildFull | 容量 3、已有 3 人时第 4 次入会失败 |
| 审批后重复申请 | ErrAlreadyMember | 已在名单里,不再排队 |
| 申请期内重复申请 | ErrAlreadyApplied | Pending 只留一行 |
| 副会长踢会长 / 成员踢副会长 | ErrBadRankChange / ErrPermissionDenied | 层级与权限两道门 |
| 会长带人离会 | ErrHeadlessGuild | 防止公会无主 |
两个结论。其一,权限的正确性来自「一张表」而非「一堆 if」:TestRankPerms 直接断言 RankOfficer.Can(PermKick)==false、RankMember.Can(PermStorageWithdraw)==false,任何一处误配立刻红。其二,入会的所有拒绝都发生在写名单之前:满员、重复、无权限,三条路径都不会留下半条脏记录——这是「状态归属」在公会上的具体落地。下一节把仓库与捐献接进来,你会看到同一个「先判后写」的纪律如何在幂等上再咬一口。
阅读导航:上一节:目录 · 下一节:13.2 公会仓库与捐献流水 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。