一、公会与联盟是两层组织
公会是一个独立的成员组织,联盟是公会之间的上层关系。把两者压成一张成员表,会出现“联盟成员”和“公会成员”权限混用的问题:联盟管理员能踢公会成员,公会会长却无法管理自己的申请队列。
推荐拆成两层:公会持有成员、角色、申请和资产;联盟只持有公会 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
小结
公会模块的核心是状态机和权限矩阵,而不是成员列表本身。角色判断放在服务端,会长退出必须有确定的继任规则,解散要进入终态并保留审计数据。资产、赛季数据和成员关系分表存放,跨服活动只同步摘要。
常见错误与边界
- 权限只在前端校验,改包后普通成员可以踢会长或解散公会。
- 会长离线没有继承规则,申请和报名长期无人处理。
- 解散只改状态,不清理成员、申请和缓存,旧数据继续被读到。
- 管理员可以踢同级,导致管理层互相清空。
- 同意申请时不再检查容量,公会成员数超过上限。
- 转让会长后原会长仍是会长,出现两个最高权限。
- 公会资产和成员表共用一行,捐献与踢人互相锁死。
- 跨服赛季数据写回区服主表,合服或解散后无法对账。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。