《游戏服务端编程实践》10.2.2 公会 / 联盟模块

拆解公会与联盟的两层组织结构、成员角色与权限矩阵,讨论加入退出、踢人解散、会长主动转让与离线继承的判定,说明资产账本和跨服赛季数据的归属,并给出带容量、终态和继任规则的 Guild 状态机实现,以及权限、继任和解散清理的测试场景;所有权限必须由服务端统一校验,不能依赖前端隐藏按钮。

一、公会与联盟是两层组织

公会是一个独立的成员组织,联盟是公会之间的上层关系。把两者压成一张成员表,会出现“联盟成员”和“公会成员”权限混用的问题:联盟管理员能踢公会成员,公会会长却无法管理自己的申请队列。

推荐拆成两层:公会持有成员、角色、申请和资产;联盟只持有公会 ID、联盟角色和联盟级规则。玩家永远先属于一个公会,联盟操作由公会代表执行,而不是由玩家个人直接执行。

二、角色与权限矩阵

操作成员管理会长
查看成员、提交申请允许允许允许
同意 / 拒绝申请拒绝允许允许
踢普通成员拒绝允许允许
踢管理员拒绝拒绝允许
提升管理员拒绝拒绝允许
转让会长拒绝拒绝允许
解散公会拒绝拒绝允许

权限判断必须在服务端完成,并且以“操作者当前角色”为准,而不是客户端传来的角色字段。前端隐藏按钮只能改善体验,不能阻止改包。每次权限变更后,旧缓存要立即失效,否则被降级的管理员还能继续踢人。

三、加入、退出、踢人与解散

加入通常经过“申请 -> 审核 -> 成为成员”三步。申请要记录时间、来源和过期时间;同意时再次检查容量,因为申请提交后公会可能已经满员。重复同意同一申请应返回幂等结果或明确的“申请不存在”,不能重复增加成员计数。

退出分为普通成员退出和会长退出。普通成员退出只需删除成员记录;会长退出必须先把职位交给一个合法继任者,否则公会会进入无人可管理的状态。踢人同样要检查目标角色:管理员不能踢同级,也不能踢会长。

解散是终态操作,只能在会长确认后执行。解散时要清空成员、申请和缓存,并把公会状态置为 closed;资产、日志和战功记录应保留为只读审计数据,不能因为解散就物理删除。

四、会长转让与离线继承

会长转让有两种入口:主动转让和离线继承。主动转让由会长发起,目标必须是当前成员;转让完成后原会长降为普通成员,避免出现两个会长。

离线继承需要一个明确的判定条件,例如会长连续 N 天未登录,或会长账号被封禁。满足条件后,系统按“管理员中贡献最高、再按入会时间最早”的顺序选出继任者,并记录一条系统日志。不要让“会长离线”直接导致公会锁死:申请、踢人和公会战报名至少应允许管理员代理。

继承过程要幂等。多个节点同时检测到会长离线时,只能有一个节点完成转让;其余节点看到会长已经变更就应放弃。可以用公会版本号或条件更新实现。

五、资产与日志不要塞进成员表

公会资产是独立账本,包含余额、流水、来源和操作者。成员表只保存角色和加入时间;把资产余额放在公会行里,每次捐献都要锁整行,和成员变更互相阻塞。

日志要记录谁在什么时间做了什么,尤其是踢人、转让、解散和资产变动。日志表只追加不更新,按公会 ID 和时间分页查询。对外展示时再脱敏,不要把内部玩家 ID 和风控标记直接返回客户端。

六、跨服公会战的数据归属

跨服公会战的报名、匹配和战报属于“赛季数据”,不应写回某个区服的公会主表。建议由跨服活动服务持有赛季 ID、公会 ID、参战名单和结果,区服只同步展示用的摘要。

归属规则要提前定清楚:公会解散后赛季积分是否保留、成员退会后已报名名额是否释放、合服后两个公会的积分如何合并。这些问题在开战前就要有唯一答案,否则只能靠人工补偿。

七、失败场景:权限只在前端校验

某次活动里,客户端把“踢人”按钮对普通成员隐藏,但服务端接口只校验了“操作者是公会成员”。一个改包玩家就能把会长踢出公会,随后公会进入无主状态,申请队列无人处理,公会战报名也失败。

修复不能只补一个接口,而是把所有公会操作收敛到一个权限函数:先检查公会状态,再检查操作者角色,最后检查目标角色是否可被操作。测试要覆盖成员、管理员、会长三种身份对每类操作的组合,而不是只测正常路径。

八、Go 实现:Guild 状态机

下面的实现把成员、申请、角色和日志放在同一个结构里,所有变更持锁完成。requireLocked 统一处理“公会已解散”“不是成员”“权限不足”三种拒绝。

// file: ch10/social/guild.go
// tier: L1
// verify: go test ./ch10/social/

package social

import (
	"errors"
	"sync"
)

// GuildRole 的数值越大权限越高。
type GuildRole int

const (
	GuildMember GuildRole = iota
	GuildManager
	GuildLeader
)

type GuildState int

const (
	GuildActive GuildState = iota
	GuildClosed
)

var (
	ErrGuildDisbanded   = errors.New("social: guild disbanded")
	ErrGuildFull        = errors.New("social: guild is full")
	ErrAlreadyMember    = errors.New("social: already a guild member")
	ErrNotMember        = errors.New("social: not a guild member")
	ErrPermissionDenied = errors.New("social: guild permission denied")
	ErrGuildApplyMiss   = errors.New("social: guild apply not found")
	ErrInvalidRole      = errors.New("social: invalid guild role change")
)

// GuildEvent 是不可变的公会日志记录。
type GuildEvent struct {
	Seq    uint64
	Kind   string
	Actor  uint64
	Target uint64
}

// Guild 的成员、申请、资产和日志都受同一把锁保护。
type Guild struct {
	mu       sync.Mutex
	id       uint64
	name     string
	capacity int
	state    GuildState
	members  map[uint64]GuildRole
	applies  map[uint64]struct{}
	events   []GuildEvent
	seq      uint64
}

func NewGuild(id uint64, name string, leaderID uint64, capacity int) *Guild {
	if capacity <= 0 {
		capacity = 1
	}
	g := &Guild{
		id: id, name: name, capacity: capacity, state: GuildActive,
		members: map[uint64]GuildRole{leaderID: GuildLeader},
		applies: make(map[uint64]struct{}),
	}
	g.recordLocked("create", leaderID, 0)
	return g
}

// Apply 只登记申请,容量在同意时还会再校验一次。
func (g *Guild) Apply(playerID uint64) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.state != GuildActive {
		return ErrGuildDisbanded
	}
	if _, ok := g.members[playerID]; ok {
		return ErrAlreadyMember
	}
	if len(g.members) >= g.capacity {
		return ErrGuildFull
	}
	g.applies[playerID] = struct{}{}
	return nil
}

func (g *Guild) Approve(actor, playerID uint64) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if err := g.requireLocked(actor, GuildManager); err != nil {
		return err
	}
	if _, ok := g.applies[playerID]; !ok {
		return ErrGuildApplyMiss
	}
	if len(g.members) >= g.capacity {
		return ErrGuildFull
	}
	delete(g.applies, playerID)
	g.members[playerID] = GuildMember
	g.recordLocked("join", actor, playerID)
	return nil
}

// Kick 不允许踢会长,也不允许同级踢同级。
func (g *Guild) Kick(actor, target uint64) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if err := g.requireLocked(actor, GuildManager); err != nil {
		return err
	}
	role, ok := g.members[target]
	if !ok {
		return ErrNotMember
	}
	if role >= g.members[actor] {
		return ErrPermissionDenied
	}
	delete(g.members, target)
	g.recordLocked("kick", actor, target)
	return nil
}

// Leave 在会长离开时把职位交给权限最高、ID 最小的成员。
func (g *Guild) Leave(playerID uint64) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.state != GuildActive {
		return ErrGuildDisbanded
	}
	role, ok := g.members[playerID]
	if !ok {
		return ErrNotMember
	}
	if role == GuildLeader {
		if len(g.members) == 1 {
			g.disbandLocked(playerID)
			return nil
		}
		successor := g.successorLocked(playerID)
		g.members[successor] = GuildLeader
		g.recordLocked("transfer", playerID, successor)
	}
	delete(g.members, playerID)
	g.recordLocked("leave", playerID, 0)
	return nil
}

// Transfer 显式转让会长,原会长降为普通成员。
func (g *Guild) Transfer(actor, target uint64) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if err := g.requireLocked(actor, GuildLeader); err != nil {
		return err
	}
	if _, ok := g.members[target]; !ok {
		return ErrNotMember
	}
	g.members[actor] = GuildMember
	g.members[target] = GuildLeader
	g.recordLocked("transfer", actor, target)
	return nil
}

// Promote 只有会长可以调用,避免管理员互相提权。
func (g *Guild) Promote(actor, target uint64) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if err := g.requireLocked(actor, GuildLeader); err != nil {
		return err
	}
	role, ok := g.members[target]
	if !ok {
		return ErrNotMember
	}
	if role != GuildMember {
		return ErrInvalidRole
	}
	g.members[target] = GuildManager
	g.recordLocked("promote", actor, target)
	return nil
}

// Disband 只允许会长执行,成员和申请一次性清空。
func (g *Guild) Disband(actor uint64) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if err := g.requireLocked(actor, GuildLeader); err != nil {
		return err
	}
	g.disbandLocked(actor)
	return nil
}

func (g *Guild) MemberCount() int {
	g.mu.Lock()
	defer g.mu.Unlock()
	return len(g.members)
}

func (g *Guild) RoleOf(playerID uint64) (GuildRole, bool) {
	g.mu.Lock()
	defer g.mu.Unlock()
	role, ok := g.members[playerID]
	return role, ok
}

func (g *Guild) State() GuildState {
	g.mu.Lock()
	defer g.mu.Unlock()
	return g.state
}

func (g *Guild) LastEvent() GuildEvent {
	g.mu.Lock()
	defer g.mu.Unlock()
	if len(g.events) == 0 {
		return GuildEvent{}
	}
	return g.events[len(g.events)-1]
}

func (g *Guild) requireLocked(playerID uint64, min GuildRole) error {
	if g.state != GuildActive {
		return ErrGuildDisbanded
	}
	role, ok := g.members[playerID]
	if !ok {
		return ErrNotMember
	}
	if role < min {
		return ErrPermissionDenied
	}
	return nil
}

func (g *Guild) successorLocked(exclude uint64) uint64 {
	var best uint64
	var bestRole GuildRole = -1
	for id, role := range g.members {
		if id == exclude {
			continue
		}
		if role > bestRole || (role == bestRole && id < best) {
			best, bestRole = id, role
		}
	}
	return best
}

func (g *Guild) disbandLocked(actor uint64) {
	g.state = GuildClosed
	g.members = make(map[uint64]GuildRole)
	g.applies = make(map[uint64]struct{})
	g.recordLocked("disband", actor, 0)
}

func (g *Guild) recordLocked(kind string, actor, target uint64) {
	g.seq++
	g.events = append(g.events, GuildEvent{g.seq, kind, actor, target})
}

Leave 在会长离开时自动选择继任者:先比角色,再比 ID,保证结果稳定。Disband 把状态置为 closed 并清空成员和申请,之后的任何写操作都会返回 ErrGuildDisbanded。

九、测试场景与运行

测试覆盖四组行为:普通成员不能审批、踢人或解散;容量满时申请被拒绝;会长退出后管理员继任且公会保持 active;解散后成员和申请清空,后续操作被拒绝。日志断言只检查最后一条事件,避免测试依赖完整事件序列。

cd .local/checks/gs-practice-lab && gofmt -l ch10/social && go vet ./ch10/social/ && go test ./ch10/social/ -count=1
ok  	gspractice/ch10/social	0.293s

小结

公会模块的核心是状态机和权限矩阵,而不是成员列表本身。角色判断放在服务端,会长退出必须有确定的继任规则,解散要进入终态并保留审计数据。资产、赛季数据和成员关系分表存放,跨服活动只同步摘要。

常见错误与边界

  • 权限只在前端校验,改包后普通成员可以踢会长或解散公会。
  • 会长离线没有继承规则,申请和报名长期无人处理。
  • 解散只改状态,不清理成员、申请和缓存,旧数据继续被读到。
  • 管理员可以踢同级,导致管理层互相清空。
  • 同意申请时不再检查容量,公会成员数超过上限。
  • 转让会长后原会长仍是会长,出现两个最高权限。
  • 公会资产和成员表共用一行,捐献与踢人互相锁死。
  • 跨服赛季数据写回区服主表,合服或解散后无法对账。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist