`register_activation_hook` 回调里抛异常,WordPress 却给我发"插件已启用":一次"伪成功"状态的断点追踪

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

上周给内部 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 里只做"文件级、本地级"操作,任何涉及网络、外部服务、复杂计算的逻辑,全部后移到首次访问时的延迟初始化,并配好降级和自检。激活那一刻的绿灯,真不能信。

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