搜索全站
插件 23 分钟阅读

WordPress 插件自建更新源:自托管更新机制实现

私有插件、客户定制插件怎么自动更新?本文用 WP 5.8 的 Update URI 机制实现自托管更新源,包含服务端 JSON 接口格式、客户端过滤器代码与更新包签名校验。

U
UFO_PF 2017-03-20 · 阅读 23 分钟
Wo 插件
阅读提示
本文约 9,019 字,预计阅读 23 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:二十个客户站点,靠邮件发 zip 更新

给客户做的定制插件、内部工具插件、不打算上架 wp.org 的商业插件,最尴尬的是更新:每次改一版,就得给二十个站点发邮件、逐个上传 zip、手动覆盖。客户那边有人更新有人没更新,半年后出现「你这插件怎么和文档不一致」的对话。

WordPress 其实为这种场景留了标准接口:从 5.8 起,插件头支持 Update URI:,配合 update_plugins_{$hostname} 过滤器,就能让私有插件走自己的更新源,体验和官方插件一模一样——后台照样有更新提示、照样一键更新、照样有 changelog。

原理分析:更新检查的完整链路

WordPress 的插件更新检查流程:

  1. 每 12 小时(或手动点击「检查更新」),WordPress 把所有已安装插件的信息打包,请求 api.wordpress.org/plugins/update-check/1.1/;
  2. 官方返回有更新的插件列表,结果存进 update_plugins 这个 site transient(12 小时);
  3. 后台「仪表盘 → 更新」和「插件」页从这个 transient 里读出。

接入自托管更新有两条路:

方式 适用 说明
Update URI: 头 + update_plugins_{$hostname} WP 5.8+ 官方推荐,不打扰 wp.org
pre_set_site_transient_update_plugins 所有版本 在结果写入 transient 前注入

必须理解的一点:如果插件头里没有 Update URI,WordPress 会默认把它上传到 wp.org 的更新检查里。只要插件的 slug 恰好和 wp.org 上某个插件重名(比如你给自己的插件起名 my-cache),就会收到「有更新」的假提示,甚至被官方插件覆盖。所以私有插件必须声明 Update URI,或者用 plugins_update_check_locales/update_plugins_{$hostname} 之外的手段把它排除。

WP 5.8 的处理逻辑是:只要插件头里有 Update URI,且主机名不是 w.org / wordpress.org,WordPress 就会跳过对该插件的官方检查,并触发 update_plugins_{$hostname} 过滤器($hostname 是 URI 的主机名部分,点号替换成下划线?实际是 wp_parse_url( $update_uri, PHP_URL_HOST ) 的结果)。

保姆级实操

第一步:给插件加 Update URI 头

<?php
/**
 * Plugin Name:       内部工具箱
 * Plugin URI:        https://tools.example.com/
 * Description:       公司内部使用的站点工具集合
 * Version:           1.4.2
 * Update URI:        https://updates.example.com/plugin/mmf-toolbox
 * Requires at least: 6.0
 * Requires PHP:      7.4
 * Author:            Example
 * License:           GPL-2.0-or-later
 */

Update URI 的主机名部分(updates.example.com)会被用作过滤器名的后缀:update_plugins_updates.example.com。

第二步:服务端提供更新信息 JSON

更新源就是「请求一个 URL,返回一段 JSON」。用 WordPress 自己当服务端最省事:

<?php
// 更新源站点上的接口(可以是一个自定义 REST 路由)
add_action( 'rest_api_init', function () {
    register_rest_route( 'mmf-updates/v1', '/toolbox', [
        'methods'             => 'GET',
        'callback'            => 'mmf_update_payload',
        'permission_callback' => '__return_true',
    ] );
} );

function mmf_update_payload( WP_REST_Request $request ) {
    return new WP_REST_Response( [
        'name'          => '内部工具箱',
        'slug'          => 'mmf-toolbox',
        'plugin'        => 'mmf-toolbox/mmf-toolbox.php',
        'version'       => '1.5.0',
        'download_url'  => 'https://updates.example.com/dl/mmf-toolbox-1.5.0.zip',
        'requires'      => '6.0',
        'tested'        => '6.6',
        'requires_php'  => '7.4',
        'last_updated'  => '2026-10-08 10:00:00',
        'author'        => 'Example',
        'sections'      => [
            'description' => '内部工具集合,本次更新修复了导出模块的编码问题。',
            'changelog'   => "<h4>1.5.0</h4><ul><li>修复 CSV 导出中文乱码</li><li>新增批量替换工具</li></ul>",
        ],
        'icons'         => [
            'default' => 'https://updates.example.com/icon-256.png',
        ],
        // 可选:给更新包做完整性校验
        'checksum'      => hash( 'sha256', file_get_contents( '/srv/packages/mmf-toolbox-1.5.0.zip' ) ),
    ], 200 );
}

返回的 JSON 结构(客户端拿到的样子):

{
  "name": "内部工具箱",
  "version": "1.5.0",
  "download_url": "https://updates.example.com/dl/mmf-toolbox-1.5.0.zip",
  "requires": "6.0",
  "tested": "6.6",
  "requires_php": "7.4",
  "sections": { "changelog": "<h4>1.5.0</h4><ul><li>修复 CSV 导出中文乱码</li></ul>" },
  "checksum": "9f2b1c...e77a"
}

关键点:version 必须高于客户端当前版本,否则不提示更新;download_url 必须是可直连下载的 zip,requires_php 与 tested 用于后台的兼容性判断。

第三步:客户端接入更新源

<?php
// 客户端插件主文件里

// 方式 A:WP 5.8+ 的 Update URI 机制(推荐)
add_filter( 'update_plugins_updates.example.com', function ( $update, $plugin_data, $plugin_file, $locales ) {
    if ( 'mmf-toolbox/mmf-toolbox.php' !== $plugin_file ) {
        return $update;
    }

    $response = wp_remote_get( 'https://updates.example.com/wp-json/mmf-updates/v1/toolbox', [
        'timeout' => 10,
        'headers' => [ 'Accept' => 'application/json' ],
    ] );

    if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
        return false;   // 请求失败:告诉 WP 没有更新,保留当前状态
    }

    $body = json_decode( wp_remote_retrieve_body( $response ), true );
    if ( empty( $body['version'] ) || ! isset( $body['download_url'] ) ) {
        return false;
    }

    // 版本比较:低于或等于当前版本就不提示
    if ( ! version_compare( $body['version'], $plugin_data['Version'], '>' ) ) {
        return false;
    }

    return [
        'id'          => 'updates.example.com/mmf-toolbox',
        'slug'        => 'mmf-toolbox',
        'plugin'      => $plugin_file,
        'version'     => $body['version'],
        'url'         => 'https://tools.example.com/changelog/',
        'package'     => $body['download_url'],
        'icons'       => $body['icons'] ?? [],
        'tested'      => $body['tested'] ?? '',
        'requires_php' => $body['requires_php'] ?? '',
        'autoupdate'  => true,
    ];
}, 10, 4 );

返回值必须是数组(表示有更新)或 false(表示无更新)。数组里 package 是下载包地址,缺了它后台会提示「更新可用」但点下去报「下载失败」。

第四步:兼容老版本 WordPress(方式 B)

add_filter( 'pre_set_site_transient_update_plugins', function ( $transient ) {
    if ( empty( $transient->checked ) ) {
        return $transient;
    }

    $plugin_file = 'mmf-toolbox/mmf-toolbox.php';
    $current     = $transient->checked[ $plugin_file ] ?? null;
    if ( ! $current ) {
        return $transient;
    }

    $remote = wp_remote_get( 'https://updates.example.com/wp-json/mmf-updates/v1/toolbox', [ 'timeout' => 10 ] );
    if ( is_wp_error( $remote ) || 200 !== wp_remote_retrieve_response_code( $remote ) ) {
        return $transient;
    }

    $body = json_decode( wp_remote_retrieve_body( $remote ), true );
    if ( ! empty( $body['version'] ) && version_compare( $body['version'], $current, '>' ) ) {
        $transient->response[ $plugin_file ] = (object) [
            'id'          => 'updates.example.com/mmf-toolbox',
            'slug'        => 'mmf-toolbox',
            'plugin'      => $plugin_file,
            'new_version' => $body['version'],
            'url'         => 'https://tools.example.com/',
            'package'     => $body['download_url'],
        ];
    }

    return $transient;
} );

注意这里字段名是 new_version(对象形式),和方式 A 的数组形式不同。

第五步:加上更新包的完整性校验

add_filter( 'upgrader_package_options', function ( $options ) {
    $options['mmf_expect_checksum'] = 'sha256:9f2b1c...e77a';   // 从更新源下发
    return $options;
} );

add_filter( 'upgrader_pre_download', function ( $reply, $package, $upgrader ) {
    if ( ! str_contains( (string) $package, 'updates.example.com' ) ) {
        return $reply;   // 只校验我们自己的包
    }
    // 下载到本地临时目录后校验哈希,不匹配就中止
    $tmp = download_url( $package );
    if ( is_wp_error( $tmp ) ) {
        return $tmp;
    }
    $hash = hash_file( 'sha256', $tmp );
    if ( ! hash_equals( '9f2b1c...e77a', $hash ) ) {
        @unlink( $tmp );
        return new WP_Error( 'mmf_bad_hash', '更新包校验失败,已中止更新。' );
    }
    return $tmp;
}, 10, 3 );

upgrader_pre_download 允许你在下载阶段接管,返回本地文件路径或 WP_Error。download_url() 是 WordPress 内置的下载函数,返回临时文件路径。

第六步:把更新源藏好

# 只允许特定来源访问更新包(按 Referer 或 Token 都只是初级防护)
location /dl/ {
    valid_referers none blocked *.example.com;
    if ($invalid_referer) { return 403; }
    add_header Cache-Control "private, no-store";
}

真正的授权应该走签名 URL(带有效期的一次性 token),而不是靠隐藏路径——隐藏路径一旦泄露就永久失效。

第七步:调试

# 强制刷新更新缓存
wp transient delete update_plugins
wp eval 'wp_clean_plugins_cache( true ); do_action( "wp_update_plugins" ); echo "checked\n";'

# 看更新检查结果
wp eval 'print_r( get_site_transient( "update_plugins" )->response );'

# 后台点"检查更新"的等价命令
wp plugin update --all --dry-run

常见踩坑

  1. 插件头没有 Update URI:私有插件被当成 wp.org 插件送去官方检查,slug 撞车时会收到别人的更新提示,甚至被覆盖安装。

  2. 返回数组里少了 package:后台显示「有更新」,点更新报「Download failed」。

  3. 案例: 某团队的更新源返回 JSON 用了 new_version 字段(方式 B 的对象格式),但客户端用的是方式 A(数组格式,字段应叫 version)。结果后台永远显示「已是最新」。排查方式是 wp eval 'print_r( get_site_transient("update_plugins")->response );' 直接看 transient 里到底存了什么——两种格式的字段名不同,这是最常踩的坑。

  4. 更新检查太频繁:wp_update_plugins 有 12 小时缓存,手动调试时记得 wp transient delete update_plugins,否则改了半天看不到效果。

  5. zip 包目录结构不对:包解压后必须是 mmf-toolbox/mmf-toolbox.php 这样的「一层目录 + 主文件」,直接把文件放在 zip 根目录下会更新失败。

  6. 没有 Requires PHP 头:客户端 PHP 版本不满足时,后台会直接隐藏更新提示,让人以为更新源坏了。

  7. 更新包 URL 用 http:现代 WordPress 对非 HTTPS 的下载会拒绝,报「下载失败」。

  8. 忘了处理请求失败:wp_remote_get 超时后直接返回空数组,会导致后台「更新」页报错。失败时统一返回 false。WordPress 私有插件分发方案

总结

判断标准:私有/定制插件一律声明 Update URI,走 update_plugins_{$hostname} 过滤器;需要兼容 WP 5.8 以下才用 pre_set_site_transient_update_plugins。

行动清单:插件头加 Update URI;服务端提供返回 version / download_url / requires / tested / requires_php / sections 的 JSON 接口;客户端过滤器里先做版本比较再返回,package 字段必填,请求失败返回 false;zip 包保持「一层目录」结构;用 upgrader_pre_download + hash_equals 做包完整性校验;更新源用签名 URL 而不是隐藏路径;调试时先清 update_plugins transient 再跑 wp plugin update --dry-run。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。