`register_activation_hook` 回调里抛异常,WordPress 却给我发"插件已启用":一次"伪成功"状态的断点追踪
上周给内部 CRM 插件写自动初始化逻辑,踩了个极其恶心的坑:register_activation_hook 的回调里明明 throw new Exception 了,WordPress 后台照样弹出"插件已启用",数据库表该建的没建,初始化数据该插的没插,但插件状态是 active 的。
更绝的是,这个异常不会进默认错误日志。不是 error_log 没配,是 WordPress 在激活流程里自己 catch 了,然后……啥也没干。我翻了 core 代码才找到这行:
// wp-admin/includes/plugin.php 约 647 行附近
$result = activate_plugin( $plugin );
if ( is_wp_error( $result ) ) {
// 走错误分支
}
// 注意:如果回调里抛的是 Exception 而非返回 WP_Error,这里直接被吞掉
WordPress 的 activate_plugin 内部用 try-catch 包住了 do_action( "activate_{$plugin}" ),但 catch 块里只处理了 WP_Error 类型的返回,原生 Exception 被默默消化,激活流程继续走完。
我的原始代码长这样,看起来人畜无害:
register_activation_hook( __FILE__, function() {
global $wpdb;
$sql = "CREATE TABLE ..."; // 建表语句
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
dbDelta( $sql );
// 初始化默认配置
$api = new External_API_Client();
$default_settings = $api->fetch_defaults(); // 这里可能抛异常!
update_option( 'my_crm_settings', $default_settings );
} );
第三方 API 那天下线了,fetch_defaults() 里抛了 ConnectionTimeout。我预期的是:建表失败 → 插件激活失败 → 用户看到红字报错。实际是:建表成功 → API 异常被吞 → 配置没写进去 → 插件显示已启用 → 用户点进设置页直接白屏报错。
这状态太脏了:表有了,配置没有,插件还激活了。用户要是这时候手动去补配置,后续升级脚本检测到表存在会跳过初始化,直接进"半残"状态。
我的修复分了两层。第一层是把回调里的"裸抛"改成显式返回 WP_Error,但发现 register_activation_hook 的回调返回值根本不会被 activate_plugin 检查,这个钩子只是 do_action,不是过滤链。所以第二层是加了一个"预检 + 事务回滚"机制:
register_activation_hook( __FILE__, function() {
global $wpdb;
$wpdb->query( 'START TRANSACTION' );
try {
// 1. 先干"纯本地、不依赖外部"的事
$sql = "CREATE TABLE IF NOT EXISTS ...";
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
dbDelta( $sql );
// 2. 外部调用前置为"可降级":失败时用本地默认值,而非抛异常阻断
$default_settings = [ /* 硬编码兜底 */ ];
try {
$api = new External_API_Client();
$remote_defaults = $api->fetch_defaults();
if ( is_array( $remote_defaults ) ) {
$default_settings = $remote_defaults;
}
} catch ( Exception $e ) {
// 记日志,但不阻断
error_log( '[CRM激活] 远程配置获取失败,使用本地默认: ' . $e->getMessage() );
}
update_option( 'my_crm_settings', $default_settings );
// 3. 最后做"状态标记",用于后续自检
update_option( 'my_crm_activation_version', MY_CRM_VERSION );
update_option( 'my_crm_activation_time', time() );
$wpdb->query( 'COMMIT' );
} catch ( Exception $e ) {
$wpdb->query( 'ROLLBACK' );
// 关键:必须手动 deactivate,否则 WordPress 会标记为 active
deactivate_plugins( plugin_basename( __FILE__ ) );
// 用 wp_die 阻断页面输出,让用户看到红屏
wp_die(
'插件激活失败: ' . esc_html( $e->getMessage() ),
'激活错误',
[ 'back_link' => true ]
);
}
} );
这里有几个我之前没意识到的点:
1. dbDelta 不会回滚
它是隐式提交的,START TRANSACTION 包不住。所以我后来改成了:先 DROP TABLE IF EXISTS 做清理测试(仅开发环境),生产环境则把建表和插数据拆成两个钩子,建表走 register_activation_hook,数据初始化走 admin_init 里的延迟执行,并加版本号做幂等。
2. deactivate_plugins 的时机
必须在 wp_die 之前调用,因为 wp_die 会终止执行。但这里有个坑:如果当前请求是 AJAX 激活(比如多站点网络的批量操作),wp_die 的输出格式不对会导致前端解析错误。我补了个判断:
if ( wp_doing_ajax() ) {
wp_send_json_error( [ 'message' => $e->getMessage() ] );
} else {
wp_die( ... );
}
3. "伪成功"的检测
现在我在插件主文件里加了一个"健康探针",每次 admin_init 时检查:
add_action( 'admin_init', function() {
if ( ! is_plugin_active( plugin_basename( __FILE__ ) ) ) return;
$activated_version = get_option( 'my_crm_activation_version' );
if ( $activated_version !== MY_CRM_VERSION ) {
// 版本对不上 = 升级脚本没跑完或初始激活异常
add_action( 'admin_notices', function() {
echo '<div class="notice notice-error">...</div>';
} );
}
} );
这个探针帮我抓到了两次测试环境的"半残"状态,都是同事手动改数据库后忘了更新版本号标记。
最后想说,WordPress 的插件激活流程在核心代码里写得很"宽容",但这种宽容对开发者来说是陷阱。我现在养成了一个习惯:register_activation_hook 里只做"文件级、本地级"操作,任何涉及网络、外部服务、复杂计算的逻辑,全部后移到首次访问时的延迟初始化,并配好降级和自检。激活那一刻的绿灯,真不能信。

