菜单项 `href` 留空时 WordPress 不会报错,但会在 `$_REQUEST['page']` 匹配阶段把权限检查"短路"掉
上周给内部 CRM 插件加"数据看板"入口,图省事把父菜单的 href 属性写成了空字符串——本意是做个纯分组标题,子菜单再各自跳转。结果测试账号(角色 custom_sales)点开子菜单里的"客户列表"正常,但直接在地址栏输 /wp-admin/admin.php?page=crm_dashboard 居然能进父级页面,虽然页面是空的,可顶部标题、左侧高亮全在,权限节点 view_crm_reports 跟没设一样。
跟了一下 wp-admin/includes/menu.php 里的逻辑,发现 WordPress 处理菜单匹配时有个坑:
// 简化后的核心逻辑,约在第 200 行附近
if ( ! empty( $parent_file ) ) {
// 尝试从 $_REQUEST['page'] 反查 menu slug
// 但如果 $menu[$hookname][2] 是空字符串,这里会提前命中"任意 page 参数"
}
更隐蔽的是 user_has_access 的检查顺序。WordPress 先拿 $_REQUEST['page'] 去 $submenu 里找匹配,找不到再回退到 $menu。父菜单的 slug 如果作为"分组锚点"存在,但 href 为空导致它没有独立可访问的 URL,权限回调 current_user_can('view_crm_reports') 实际上只在渲染左侧栏时执行了一次——用于决定显不显示这个菜单项,而不是拦截 HTTP 请求。
我的原始代码长这样:
add_menu_page(
'CRM 中心', // page_title
'CRM', // menu_title
'view_crm_reports', // capability —— 这里其实成了"显示门槛"而非"访问门槛"
'crm_dashboard', // menu_slug
'', // function —— 空!没回调
'dashicons-chart-area',
30
);
子菜单正常注册了,但父菜单本身成了"幽灵入口":左侧栏里点不了(没 href),直接输 URL 能进(因为 admin.php?page=crm_dashboard 这个路由存在,只是没内容)。更糟的是,如果我在父菜单回调里补一个 wp_die('无权访问'),那左侧栏的父级标题会整组消失——因为 WordPress 渲染菜单时也会调那个回调来判断"有没有内容可显示"。
现在的解法是给父菜单一个"占位回调",里面只做权限拦截和重定向:
add_menu_page(
'CRM 中心',
'CRM',
'view_crm_reports', // 显示门槛
'crm_dashboard',
'__crm_parent_gatekeeper', // 不再留空
'dashicons-chart-area',
30
);
function __crm_parent_gatekeeper() {
// 二次校验:即使菜单显示了,直接访问也要拦
if ( ! current_user_can( 'view_crm_reports' ) ) {
wp_die( __( '需要 view_crm_reports 权限' ) );
}
// 父菜单不需要独立页面,把用户分发到默认子菜单
$first_sub = menu_page_url( 'crm_customer_list', false );
wp_safe_redirect( $first_sub );
exit;
}
但这里又踩了一个子菜单 slug 的坑:menu_page_url() 在子菜单里查找时,如果 $submenu['crm_dashboard'] 数组的第一个元素权限比父菜单宽松,WordPress 不会帮你过滤——它只返回第一个注册的子菜单 URL,不管当前用户能不能进。所以我加了个显式遍历:
function __crm_parent_gatekeeper() {
global $submenu;
if ( ! current_user_can( 'view_crm_reports' ) ) {
wp_die( '无权访问' );
}
$target = '';
if ( isset( $submenu['crm_dashboard'] ) ) {
foreach ( $submenu['crm_dashboard'] as $item ) {
// $item[1] 是 capability
if ( current_user_can( $item[1] ) ) {
$target = menu_page_url( $item[2], false );
break;
}
}
}
wp_safe_redirect( $target ?: admin_url() );
exit;
}
另外发现个冷知识:add_menu_page 的 $position 参数如果和现有菜单冲突,WordPress 会静默覆盖,但不会合并 $submenu 数组。我之前把 position 设成 26.5(想在评论和外观之间),结果和另一个插件撞了,父菜单显示的是我的标题,子菜单里却混进了对方的条目——因为两个插件用了同一个顶层 slug 的哈希前缀。后来改成字符串 '26.94421' 才避开整数碰撞。
最后提一个权限节点的声明细节:自定义 capability 不要只在 add_menu_page 里用字符串硬编码,要在插件激活时写进角色里,否则超级管理员能看到,但新创建的角色(通过第三方角色编辑器)不会自动继承。我的习惯是单独放一个 capability-map.php,激活时遍历:
function crm_activate_caps() {
$caps = array(
'view_crm_reports' => array( 'administrator', 'crm_manager' ),
'edit_crm_customers' => array( 'administrator', 'crm_manager', 'crm_sales' ),
'delete_crm_orders' => array( 'administrator' ),
);
foreach ( $caps as $cap => $roles ) {
foreach ( $roles as $role_name ) {
$role = get_role( $role_name );
if ( $role && ! $role->has_cap( $cap ) ) {
$role->add_cap( $cap );
}
}
}
}
但这里有个时序问题:如果用户在插件激活后、再安装角色编辑器并新建角色,这些 capability 不会自动追加。目前我的妥协方案是在 admin_init 里做"缺失补录",但加了 wp_cache_get('crm_caps_synced') 防止每次请求都查库。有没有更干净的做法?