Zsens Admin 插件菜单"幽灵节点"事件:路由已注册、权限已声明,为何管理员仍看到 403 白屏

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 60 浏览 0 回复

上周帮同事排查一个诡异问题:插件安装后,后台左侧菜单正常渲染,点击却直接 403。更离谱的是,超管账号也中招。折腾两小时才发现,问题根本不在鉴权逻辑,而在菜单注册与权限节点声明的时序错位。这篇把踩坑细节摊开,给同样被"幽灵节点"折磨的兄弟提个醒。

一、菜单注册的三层皮肤,你剥到哪一层了

Zsens Admin 的菜单不是单点注册,而是皮肤层 → 节点层 → 路由层三级穿透。很多人(包括我)以为在 `Plugin.php` 里写完 `hook_menu` 就完事,实际上:

  • 皮肤层:控制后台左侧导航是否显示,由 `admin_menu` 数据表 + 缓存驱动
  • 节点层:权限系统的最小单元,决定"谁能看见这个入口",存在 `admin_node`
  • 路由层:真正的请求分发,由插件的 `route.php` 或注解接管

三层任意一层漏配,都会出现"看得见点不动"或"点得动看不见"的灵异现象。我那次就是路由层通了、皮肤层写了,但节点层的 node_type 填成了 action 而非 controller,导致权限树遍历时直接跳过。

二、权限节点声明的四个隐形字段

直接上我现在的标准模板,重点看注释:

// Plugin.php 中 hook_node 的完整声明
public function hookNode()
{
    return [
        [
            'name'       => 'zsens_ai_config',      // 节点标识,全局唯一
            'title'      => 'AI助手配置',            // 显示名称
            'node_type'  => 'controller',           // 【坑点】controller/action/page 三选一
            'path'       => 'zsens/admin/config',   // 对应路由 path,必须带插件前缀
            'auth_type'  => 1,                      // 0=无需登录 1=登录即可 2=需授权
            'is_menu'    => 1,                      // 【坑点】1 才会进入权限分配列表
            'sort'       => 100,
            'pid'        => 0,                      // 0=顶级菜单,非0时务必确认父节点存在
            'icon'       => 'fa-robot',
        ],
        // 子节点示例:注意 pid 指向父节点的 name,不是自增 ID
        [
            'name'       => 'zsens_ai_config_save',
            'title'      => '保存配置',
            'node_type'  => 'action',               // 纯接口用 action,不显示在菜单
            'path'       => 'zsens/admin/config/save',
            'auth_type'  => 2,                      // 需显式授权,防止接口裸奔
            'is_menu'    => 0,                      // 不在导航显示
            'pid'        => 'zsens_ai_config',      // 【关键】字符串匹配,写错就孤儿
        ]
    ];
}

最容易翻车的两个字段:

1. node_typeis_menu 的组合陷阱

如果你把菜单项的 node_type 设成 action,权限系统会认为这是一个"操作按钮"而非"页面入口",即使 is_menu=1 也不会渲染到导航树。反过来,controller 类型的节点如果 is_menu=0,它会出现在"权限分配"界面但左侧导航不显示——这种"半隐身"状态有时反而是需要的,比如仅超管可见的调试页。

2. pid 的字符串依赖

官方文档没强调的是,子节点的 pid 匹配的是父节点的 name 字段,而不是数据库自增 ID。我有一次重构把父节点 namezsens_ai 改成 zsens_ai_main,忘了同步改所有子节点的 pid,结果整棵子树从权限界面蒸发。更坑的是,如果父节点不存在,子节点不会报错,只是静默失效。

三、时序问题:安装、升级、缓存刷新谁先谁后

菜单和节点的注册不是原子操作。插件安装流程大概是:

  1. 执行 install.sql
  2. 触发 Plugin::install() 方法
  3. 系统扫描 hook_menuhook_node 写入数据库
  4. 刷新 admin_menuadmin_node 缓存

问题出在第三步和第四步之间。如果你的 hook_node 依赖了某个动态计算的值(比如读取当前版本号拼接节点名),而缓存刷新时这个值还没落库,就会出现数据库有数据、缓存是旧快照的不一致。我的修复方案是在 install() 末尾手动清缓存:

public function install()
{
    // ... 原有安装逻辑
    
    // 强制重建菜单与权限缓存,防止 hook_node 动态值与缓存快照错位
    \think\facade\Cache::tag('admin_menu')->clear();
    \think\facade\Cache::tag('admin_node')->clear();
    
    // 触发一次主动重建,避免首次访问时的冷加载延迟
    event('AdminMenuRebuild');
}

升级场景更隐蔽。Zsens Admin 的升级不会自动清理已删除的节点,如果你在某版本把 zsens_ai_log 节点改名成 zsens_ai_record,旧节点会残留在 admin_node 表里,管理员权限分配界面出现两个同名入口,一个有效一个 403。我的做法是在 upgrade() 里显式做节点对账:

public function upgrade($currentVersion, $targetVersion)
{
    $nodeModel = new \app\admin\model\AdminNode();
    
    // 版本 1.2.0 重构了日志模块节点命名
    if (version_compare($currentVersion, '1.2.0', '<')) {
        $nodeModel->where('name', 'zsens_ai_log')->delete();
        // 注意:delete 后必须清缓存,否则权限树仍有残留引用
        \think\facade\Cache::tag('admin_node')->clear();
    }
}

四、路由 path 的前缀约定与反向解析

节点声明的 path 必须与路由定义严格一致,包括前缀。我习惯在插件里用一个常量收敛:

// config/zsens.php
return [
    'route_prefix' => 'zsens/admin',
];

// route.php
Route::group(config('zsens.route_prefix'), function () {
    Route::get('config', 'ConfigController@index');
    Route::post('config/save', 'ConfigController@save');
})->middleware(\app\admin\middleware\AuthCheck::class);

// Plugin.php 中 hook_node 统一引用
$prefix = config('zsens.route_prefix');
// 然后拼接 $prefix . '/config'

这样做还有个好处:当插件需要适配不同部署路径时,改一处配置即可,不用翻遍所有节点声明。但注意 config/zsens.php 必须在插件安装时就存在,否则 hook_node 首次执行时读不到值,会 fallback 成空字符串,导致 path 变成 /config 而非 zsens/admin/config,路由匹配直接 404。

五、调试"幽灵节点"的三板斧

最后分享我现在的排查流程,按顺序来能省很多时间:

  1. 看缓存:直接查 runtime/cache/ 下的 admin_menuadmin_node 序列化文件,确认你的节点有没有被编译进去,pid 指向是否正确
  2. 看数据库:对比 admin_node 表与缓存文件,确认不是"库里有、缓存没刷新"的老问题
  3. 看路由匹配:在目标 Controller 构造函数第一行打 die('route hit'),确认请求能走到控制器。如果这里没触发,说明路由
评论0
回复 · 0
还没有回复
微信客服 微信客服