子菜单 `slug` 撞车导致父级整页 403:我排查权限节点时才发现 WordPress 的 `menu_page_url` 有套"同名吞并"暗规则
上周给 Zsens Admin 加了个"日志审计"子模块,后台菜单结构大概是:
```php // 父级 add_menu_page( '审计中心', '审计中心', 'manage_audit', 'zsens-audit', [$this, 'render_dashboard'], 'dashicons-visibility', 30 ); // 子级 1:操作日志 add_submenu_page( 'zsens-audit', '操作日志', '操作日志', 'view_audit_logs', 'zsens-audit-logs', [$this, 'render_logs'] ); // 子级 2:登录审计(新加的) add_submenu_page( 'zsens-audit', '登录审计', '登录审计', 'view_login_audit', 'zsens-audit-logs', // ← 手滑复制了上面的 slug [$this, 'render_login_audit'] ); ```结果诡异的事情发生了:拥有 view_login_audit 权限的角色点进"登录审计",直接 403;更离谱的是,连原本能正常访问"操作日志"的角色,现在也只能看到"登录审计"的内容,原来的日志页彻底消失。
我第一反应是 current_user_can 判断写岔了,在 render_login_audit 里加了一堆 var_dump,发现请求根本进不到回调里,403 是 WordPress 在分发阶段就拦下来的。
翻 wp-admin/includes/plugin.php 里的 add_submenu_page 实现,才看到这段"沉默的暴力":
WordPress 对子菜单用的是追加到数组,而后续 menu_page_url('zsens-audit-logs') 或者权限校验时,get_plugin_page_hook 内部会走到 $admin_page_hooks[$plugin_page] —— 这里的 $plugin_page 就是 slug,后注册的同名 slug 会直接覆盖 hook 映射。更坑的是 user_can_access_admin_page 这个函数,它从 $_parent_pages[$page_hook] 里找父级,然后拿最后注册的那个子菜单的 capability 来做权限判断。
也就是说,我的两个子菜单虽然挂在同一个父级下,但第二个因为 slug 重复,把第一个的 hook 注册给"借尸还魂"了,而权限校验时用的是后注册的 view_login_audit。所以:
- 只有
view_login_audit的角色 → 能进,但看到的是登录审计内容 - 只有
view_audit_logs的角色 → 403,因为校验时匹配的是后注册的 capability - 两个权限都有的角色 → 永远只能看到登录审计,操作日志的回调被彻底覆盖
而且 WordPress 全程没报错、没警告,WP_DEBUG 开满也是一片寂静。这种"同名吞并"在 add_menu_page 和 add_submenu_page 之间也会发生——如果你不小心让顶级菜单的 slug 和某个子菜单撞了,路由分发会直接乱套。
我现在给团队加了两条硬性规范:
1. slug 命名空间隔离
不再手写裸字符串,统一用插件前缀 + 模块 + 动作的三段式:
```php private function make_slug(string $section, string $action): string { return sprintf('zsens-%s-%s', $section, $action); } // 生成 zsens-audit-logs-view, zsens-audit-login-view ```2. 注册时的防御性断言
在 admin_menu 钩子末尾加一层自检:
3. 权限节点声明和 slug 解耦文档
之前我的权限节点定义和 slug 注册散落在两个文件,现在强制要求同一个模块的 capability、slug、回调 在同一个数组里声明,再统一遍历注册:
这样至少能保证"看一眼就能发现 slug 有没有撞"。
最后提一个更隐蔽的坑:如果你用 remove_submenu_page 清理菜单,它返回的是被移除的整个子菜单数组项,但并不会清理 $admin_page_hooks 里的映射。如果你移除后又用相同 slug 重新注册,残留的 hook 指向会导致 screen_base 判断异常,get_current_screen 返回的对象属性全是错的。
有踩过类似坑的老哥吗?或者你们有没有更狠的静态检测手段,能在 CI 阶段就把 slug 冲突扫出来?