Zsens Admin 插件路由注册"静默死亡":REST 端点返回 404,但 `register_rest_route` 明明执行了——我的命名空间被另一个插件"借尸还魂"
上周接了个诡异的工单:用户说 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_routes 再 register_rest_route,拿到的 $routes 不含你刚注册的。我上面的代码用了嵌套 add_action 把实际注册推迟到下一个微阶段,就是为了绕过这个缓存行为。
最后给个"路由验尸单",遇到 REST 404 按这个顺序查:
rest_get_server()->get_routes()看路由在不在表——不在就是注册阶段挂了- 在表但 404:检查
methods匹配、args校验失败、permission_callback返回 WP_Error(这些返回的不是 403 是 404,WordPress 的 REST 哲学) - 在表、方法对、权限过,但回调不进:99% 是另一个 handler 在前面匹配成功但
die了,或者做了奇怪的rest_ensure_response包装导致提前返回 - 命名空间"部分匹配":用
rest_get_server()->get_routes( '' )拿全部,grep你的关键词,看有没有"李鬼"
那个第三方插件我已经发邮件了,但对方更新周期以月计。防御性编程的底线应该是"不破坏未知调用方",而不是"清理我认为的脏数据"。这次教训让我给所有新插件的 REST 命名空间加了 vendor-product-env/version 的三段式规范,宁可 URL 长点,也不跟全世界撞衫。

