Zsens Admin 插件路由注册"静默死亡":REST 端点返回 404,但 `register_rest_route` 明明执行了——我的命名空间被另一个插件"借尸还魂"

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

上周接了个诡异的工单:用户说 Zsens Admin 的批量导出接口突然 404,但插件没更新、WordPress 核心没升级,就装了个"某数据同步插件"。我本地复现后,register_rest_route 的回调里加 error_log,压根不进。不是权限问题,不是 permission_callback 返回 false,是路由表本身没这条记录。

先上我的"验尸代码",以后遇到 REST 404 直接贴:

// 在任意可执行位置,比如 admin_init
add_action( 'admin_init', function() {
    $routes = rest_get_server()->get_routes();
    
    // 精确匹配我的命名空间
    $mine = array_filter( $routes, function( $key ) {
        return strpos( $key, 'zsens-admin/v2' ) === 0;
    }, ARRAY_FILTER_USE_KEY );
    
    error_log( '我的路由: ' . print_r( array_keys( $mine ), true ) );
    
    // 看看有没有"撞衫"的
    $impostors = array_filter( $routes, function( $key ) {
        return preg_match( '#/zsens-admin#', $key ); // 注意前后可能有其他字符
    }, ARRAY_FILTER_USE_KEY );
    
    error_log( '可疑路由: ' . print_r( array_keys( $impostors ), true ) );
} );

跑完我傻了:zsens-admin/v2 下面只有 3 条路由,但我注册了 7 条。更离谱的是,多出个 third-party/v1/zsens-admin/export——那个新插件把我的命名空间关键词嵌进它自己的路由里了?不,反过来:它先注册了 zsens-admin 这个基础命名空间,但版本号是 v1,然后我的 zsens-admin/v2 在 WordPress 的路由合并逻辑里被"吞"了部分。

深挖 WP_REST_Server::register_route 的源码,发现命名空间冲突不是"同名覆盖"那么简单。WordPress 用 $namespace 做路由分组键,但实际匹配走的是 preg_match 对完整路由正则。真正的问题是:另一个插件在 rest_api_init 的优先级 9(默认 10)抢先执行,注册了 zsens-admin/v1,然后它的代码里有段"兼容处理":

// 第三方插件的"骚操作"
add_action( 'rest_api_init', function() {
    // 它想"接管"所有 zsens-admin 路由
    $existing = rest_get_server()->get_routes( 'zsens-admin' );
    if ( ! empty( $existing ) ) {
        // 这里!!它把 v2 的路由当成 v1 的"遗留数据"清掉了
        foreach ( $existing as $route => $handlers ) {
            if ( strpos( $route, '/v2/' ) !== false ) {
                unset( $GLOBALS['wp_rest_server']->routes[ $route ] );
            }
        }
    }
}, 9 );

这代码明显是"防御式编程"过头的恶果:作者假设 zsens-admin 只有它自己用,看到 v2 认为是"未来不兼容版本"直接抹掉。我的插件在优先级 10 执行时,register_rest_route 能正常跑完不报错,但路由表在那一刻已经被污染了。

我的修复不是改优先级去"抢跑",那会变成军备竞赛。而是给命名空间加租户隔离后缀,同时保留兼容层:

// 新的命名空间策略
define( 'ZSENS_ADMIN_REST_NS', 'zsens-admin-prod/v2' ); // 加环境标识

// 但老用户可能硬编码了前端 URL,做一层动态探测
add_action( 'rest_api_init', function() {
    $legacy_ns = 'zsens-admin/v2';
    $new_ns    = ZSENS_ADMIN_REST_NS;
    
    // 如果检测到"侵略者"注册了 legacy,我们抢注一个 alias
    $routes = rest_get_server()->get_routes();
    $has_intruder = isset( $routes[ $legacy_ns ] ) 
        && ! isset( $routes[ $legacy_ns ]['zsens-admin/v2/export-batch'] ); // 我的核心路由缺失
    
    if ( $has_intruder ) {
        // 把新命名空间的路由同时注册到 legacy,但用更高优先级的 handler
        // 这样匹配时先走到我的代码,里面再决定是代理还是拒绝
        add_action( 'rest_api_init', function() use ( $legacy_ns ) {
            register_rest_route( $legacy_ns, '/export-batch', [
                'methods'             => 'POST',
                'callback'            => [ __CLASS__, 'handle_export' ],
                'permission_callback' => [ __CLASS__, 'check_export_perm' ],
                'args'                => self::get_export_args(),
            ] );
        }, 11 ); // 比侵略者晚,但覆盖它的同名路由
    }
    
    // 正常注册新命名空间
    self::register_real_routes( $new_ns );
}, 10 );

这里有个细节:register_rest_route 对同一路径重复注册时,后注册的 handler 会追加到数组前面,匹配时按数组顺序走。所以优先级 11 的同名路由会"覆盖"优先级 9 的,不是删除,是优先匹配。

但最脏的坑是 rest_get_server()->get_routes() 的返回值不是实时写透的。如果你在同一个 rest_api_init 钩子周期内先 get_routesregister_rest_route,拿到的 $routes 不含你刚注册的。我上面的代码用了嵌套 add_action 把实际注册推迟到下一个微阶段,就是为了绕过这个缓存行为。

最后给个"路由验尸单",遇到 REST 404 按这个顺序查:

  1. rest_get_server()->get_routes() 看路由在不在表——不在就是注册阶段挂了
  2. 在表但 404:检查 methods 匹配、args 校验失败、permission_callback 返回 WP_Error(这些返回的不是 403 是 404,WordPress 的 REST 哲学)
  3. 在表、方法对、权限过,但回调不进:99% 是另一个 handler 在前面匹配成功但 die 了,或者做了奇怪的 rest_ensure_response 包装导致提前返回
  4. 命名空间"部分匹配":用 rest_get_server()->get_routes( '' ) 拿全部,grep 你的关键词,看有没有"李鬼"

那个第三方插件我已经发邮件了,但对方更新周期以月计。防御性编程的底线应该是"不破坏未知调用方",而不是"清理我认为的脏数据"。这次教训让我给所有新插件的 REST 命名空间加了 vendor-product-env/version 的三段式规范,宁可 URL 长点,也不跟全世界撞衫。

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