菜单注册时 `add_menu_page` 的 `$position` 参数是个"浮点数陷阱":我用 `3.00001` 把插件菜单钉在"媒体"和"页面"之间,却踩了 PHP 数组键的隐式转换坑

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

上周给内部工单系统写插件,需求是把菜单塞进"媒体"(position 4)和"页面"(position 5)中间。第一反应:填 4.5 不就行了?结果菜单直接消失,连报错都没有。

翻源码才发现 WordPress 用的是 `$menu[ (string) $position ]`,但 PHP 数组键遇到数字字符串会强制转 int。更坑的是,如果 4.5 先注册、后面有个插件占了 4,那 4.5 的浮点键在比较时会被当成 4 处理,导致覆盖或者顺序错乱。

现在我的写法是故意错开常用整数,用带小数且不会被隐式转换撞车的值:

// 危险:4.5 在某些 PHP 版本/场景下行为不稳定
add_menu_page(
    '工单中心',
    '工单',
    'read_private_tickets', // 自定义 capability
    'ticket-hub',
    'render_hub',
    'dashicons-tickets',
    4.0001 // 不是 4.5,刻意避开 .5 这种"半整数"
);

但单这么写还不够。WordPress 的 `$menu` 全局数组在排序时有个潜规则:相同整数键后覆盖前,而浮点键的排序依赖 PHP 的 ksort 行为。如果你的插件和另一个插件都用了 4.0001,后加载的那个会静默覆盖你的菜单项,连钩子冲突提示都没有。

我现在会在注册前做一次"占位探测",用个小技巧把冲突暴露出来:

add_action( 'admin_menu', function() {
    global $menu;
    
    $desired_pos = '4.00001'; // 字符串键,强制不走整数转换
    $tries = 0;
    
    while ( isset( $menu[ $desired_pos ] ) && $tries < 100 ) {
        $desired_pos = '4.00001' . str_pad( ++$tries, 3, '0', STR_PAD_LEFT );
    }
    
    // 如果 100 次都撞了,说明这位置被恶意占满,直接抛异常
    if ( isset( $menu[ $desired_pos ] ) ) {
        do_action( 'myplugin_menu_collision', $desired_pos, $menu );
        $desired_pos = null; //  fallback 到末尾
    }
    
    if ( $desired_pos ) {
        add_menu_page( /* ... */, floatval( $desired_pos ) );
    }
}, 9 ); // 优先级 9,抢在大部分插件的 10 之前执行

权限节点这边有个更隐蔽的坑。`add_menu_page` 的第三个参数是 capability,但它同时承担了"显示菜单"和"访问页面"两个职责。如果你把 capability 设成 `manage_options`,那所有能进设置页面的角色都能看到你的菜单——哪怕你本意是只给某个自定义角色看。

我的解法是"声明分离":菜单显示用一个宽松的 capability,页面访问再用 `user_has_cap` 或者 `current_user_can` 做二次拦截。但这里有个顺序问题——`user_has_cap` 过滤的是 `map_meta_cap`,而菜单渲染时 WordPress 已经缓存了当前用户的 capabilities,如果你在插件加载后期才动态添加 cap,菜单可能显示不出来。

所以我现在把权限节点拆成三块声明:

// 1. 在插件激活时写入角色(只执行一次)
register_activation_hook( __FILE__, function() {
    $role = get_role( 'ticket_agent' );
    if ( $role ) {
        $role->add_cap( 'ticket_hub_access', true );  // 菜单显示
        $role->add_cap( 'ticket_read_own', true );    // 数据读取
        $role->add_cap( 'ticket_escalate', false );   // 显式声明"无此权限",方便后续 audit
    }
});

// 2. 菜单注册用 "read" 这种基础 cap 保证显示,实际拦截在页面函数里
add_menu_page(
    '工单中心',
    '工单',
    'read', // 最低门槛,确保菜单可见
    'ticket-hub',
    function() {
        // 3. 页面内部二次鉴权,同时处理"有菜单权限但无数据权限"的边缘情况
        if ( ! current_user_can( 'ticket_hub_access' ) ) {
            wp_die( '需要工单专员身份', 403 );
        }
        render_hub();
    },
    'dashicons-tickets',
    4.00001
);

最后一个细节:子菜单的 `$parent_slug` 如果和父菜单的 `$menu_slug` 完全一致,WordPress 会把它当成"第一个默认子菜单"处理,自动复制父菜单的标题和 capability。这会导致你的权限节点声明被"稀释"——子菜单继承了父菜单的 cap,而不是你单独声明的那个。

我的习惯是给父菜单 slug 加个 `-root` 后缀,真正的功能页用不带后缀的 slug,这样第一个子菜单可以独立声明 cap:

add_menu_page( '工单中心', '工单', 'read', 'ticket-hub-root', '...' );
add_submenu_page( 'ticket-hub-root', '我的工单', '我的', 'ticket_read_own', 'ticket-hub', 'render_list' );
add_submenu_page( 'ticket-hub-root', '待分配', '分配池', 'ticket_dispatch', 'ticket-hub-dispatch', 'render_pool' );

这样父菜单只是个"容器",点击后默认展开第一个子菜单,而每个子菜单的权限节点都是独立声明、互不干扰的。调试时可以在 `admin_menu` 钩子末尾 `var_dump( $submenu['ticket-hub-root'] )` 直接看到完整的权限映射表,比翻源码快得多。

评论0
回复 · 0
还没有回复
微信客服 微信客服