项目里有个 GM 后台系统,最初只是几个硬编码的 RemoteEvent handler,后来命令从 5 个涨到 30 多个,权限也开始分 Owner / Operator / None 三级,原来的写法撑不住了。重构之后,核心思路是把"加命令"变成"加一个文件"——这篇记录一下架构。
整体结构
Client Server
┌──────────────────┐ ┌──────────────────────┐
│ CommandPanel │──FireServer──▶│ PermissionManager │
│ (UI + 命令列表) │ │ (权限判定 + GUI分发) │
│ │ │ │ │
│ OptionFactory │ │ CommandRouter │
│ + 20+ Options │ │ (Rate→Perm→Schema) │
│ │ │ │ │
│ Client Commands │ │ 30+ Command Modules │
└──────────────────┘ └──────────────────────┘
设计上三个原则:
- 命令即模块 — 每个管理命令是独立 ModuleScript,自带 Name / Permission / Schema / Execute
- 服务端是唯一权威 — 客户端做 UX 预检,但真正的安全校验全在服务端
- 声明式配置 — 选项选择器和 Schema 都用声明式,样板代码控制到最少
权限:三级 + 双维度
权限分三级:Owner(全部命令,含数据重置)、Operator(常规运维)、None。
判定链是从宽松到严格逐级 fallback:
Studio 会话 + 开发环境 → Owner
玩家在开发/测试 Place → Owner
Google Sheets 名单标注 Owner → Owner
Google Sheets 名单里有 → Operator
都不是 → None
管理名单存在 Google Sheets 里,运营那边改完立刻生效——Sheet.Changed 事件驱动,遍历在线玩家逐个刷新权限。
但真正有意思的是双维度门控。权限等级只是内层,外层还有一个 Place 维度:生产环境不管你是谁,都看不到数据重置、数据覆写这类破坏性命令。
local function buildVisibleCommands(myLevel)
local commands = { /* 24个常规命令 */ }
-- Place 是外层门控:生产环境到此为止
if not isTestPlace then return commands end
-- 测试环境才追加高级命令
table.insert(commands, { 'WritePlayerData' })
table.insert(commands, { 'WipePlayerData', 'Player' })
-- Owner 专属的破坏性测试命令
if myLevel == 'Owner' then
table.insert(commands, { 'InjectCorruptedItem', ... })
end
return commands
end
这样即使 Owner 权限的人在生产环境手滑点了不该点的按钮,命令列表里根本不会出现。多一层保护总比事后道歉强。
命令系统:自动注册
每个命令模块长这样:
return {
Name = "GrantCurrency",
Permission = "Operator",
Schema = {
CurrencyType = "string",
TargetPlayer = "userId",
Quantity = "positiveNumber",
},
Execute = function(ctx, args)
local target = Players:GetPlayerByUserId(args.TargetPlayer)
if not target then return false, "target offline" end
ctx.Framework.Modules.EconomyService:Grant(target, args.CurrencyType, args.Quantity, {
DenyBoost = true,
TransactionType = "GM",
})
return true
end,
}
路由模块在 Init 阶段扫描 Framework 加载的所有模块,匹配 type(mod.Name) == 'string' and type(mod.Execute) == 'function' 的就自动注册:
function CommandRouter:scanModules()
for moduleName, mod in pairs(self.Framework.Modules) do
if type(mod) == 'table'
and type(mod.Name) == 'string'
and type(mod.Execute) == 'function' then
self.commandMap[mod.Name] = mod
end
end
end
加新命令就是三步:建模块文件 → 按规范写 Name/Schema/Execute → 在客户端面板的命令列表里加一行 UI 配置。不用改路由模块、不用手动注册、不用写路由。这个自动发现机制是整个系统里我最满意的部分。
安全:五层防线
路由模块的 Process 方法是唯一入口,所有请求走同一条管道:
args 类型检查 → 命令是否存在 → 权限够不够 → 频率限制 → Schema 校验 → Execute
Schema 类型系统是双端共享的,客户端和服务端用同一份代码:
local TYPE_VALIDATORS = {
['string'] = function(v) ... end,
['number'] = function(v) ... end,
['positiveNumber'] = function(v) ... end,
['userId'] = function(v) ... end,
['boolean'] = function(v) ... end,
}
? 后缀表示可选字段。比如 "string?" 表示这个参数可以不传,但传了就必须是非空字符串。类型检查集中在一个模块里,命令模块只需要声明字段名和类型,不用手写 if-else 判断参数合法性。
审计日志用结构化格式输出,方便 grep:
[GM] EXEC uid=12345 player=X cmd=GrantCurrency
[GM] DENIED uid=67890 player=Y cmd=WipePlayerData level=None need=Owner
[GM] THROTTLED uid=12345 player=X cmd=GrantCurrency rate-per-second exceeded
频率限制是每秒 5 次 / 每分钟 60 次,用滑动窗口实现的。不算复杂,但够用——管理命令本来就不是高频操作。
客户端 UI:选项工厂
每个命令的参数(目标玩家、道具、数量等)对应一个选项模块。选项模块用工厂函数创建,声明式配置:
-- QuantityOption: 数量选择器
return createOption({
Entries = function(prev, ctx)
-- 根据前序选择动态调整选项
return { 1, 25, 1000, 100000, 10000000, 1000000000 }
end,
GetLabel = function(amount, _, ctx)
return ctx.Framework.Modules.NumberFormatter:Abbreviate(amount) -- "1k" / "10M"
end,
GetValue = function(amount) return amount end,
})
工厂函数支持 Entries / GetLabel / GetValue / GetTier / GetImage / Filter / Sort 这些配置项,覆盖了绝大多数场景。少数特殊需求(比如 PlayerOption 的 "You" 按钮需要不同的布局)走 BuildOnly 逃生舱,完全自定义 UI。
命令流:用户点击命令按钮 → 遍历参数列表 → 对每个参数调对应选项模块弹出下拉菜单 → 用户选择或取消 → 收集完所有参数后 dispatch。如果是客户端命令(比如本地预览场景效果)就本地执行,否则走 FireServer。
几个取舍
为什么用 Google Sheets 而不是数据库? 因为运营那边已经在用 Sheet 管理其他配置,不需要再搭一套后台。代价是权限名单用 player.Name 做 key 而不是 UserId——理论上如果有人抢注了离职 GM 的用户名就能拿到权限。但 Sheet 更新频率够高,实际风险可控。如果将来要改,迁移到 UserId 也很简单。
为什么客户端也做一次参数校验? 不是为了安全——服务端永远会再做一次权威校验。纯粹是为了用户体验:在参数格式不对的时候立刻弹提示,而不是等 FireServer 往返一次才发现问题。
选项模块的懒加载:二十多个选项模块如果全在启动时 require,拖慢客户端面板的加载。所以改成按需加载 + 缓存——第一次用到某个选项时才 require,之后从缓存取。
总结
这套 GM 后台最核心的价值是可扩展性:加一个命令 = 加一个文件 + 一行 UI 配置。五层防线保证没有单点可以被绕过,Place × Level 双维度门控防止生产环境手滑。
代码量不算大——权限管理 ~130 行,路由模块 ~170 行,Schema 校验 ~100 行,客户端面板 ~390 行,选项工厂 ~110 行。加上 30 多个命令模块和 20 多个选项模块,总共也就两千行左右。对于一个支撑 30+ 种管理操作的完整后台系统来说,这个规模相当克制。
如果你也在做 Roblox 游戏的 GM 后台,路由模块的自动注册逻辑和 Schema 的类型系统可以直接拿去用——配上你自己的权限判定和 UI 层,基本就能跑起来。
Roblox 游戏 GM 后台的插件化命令系统
https://xn--wgv44dw7s.cn/2026/07/21/nczKURuZ
评论