项目里有个 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 │
└──────────────────┘               └──────────────────────┘

设计上三个原则:

  1. 命令即模块 — 每个管理命令是独立 ModuleScript,自带 Name / Permission / Schema / Execute
  2. 服务端是唯一权威 — 客户端做 UX 预检,但真正的安全校验全在服务端
  3. 声明式配置 — 选项选择器和 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 层,基本就能跑起来。