本文约 9,019 字,预计阅读 23 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:二十个客户站点,靠邮件发 zip 更新
给客户做的定制插件、内部工具插件、不打算上架 wp.org 的商业插件,最尴尬的是更新:每次改一版,就得给二十个站点发邮件、逐个上传 zip、手动覆盖。客户那边有人更新有人没更新,半年后出现「你这插件怎么和文档不一致」的对话。
WordPress 其实为这种场景留了标准接口:从 5.8 起,插件头支持 Update URI:,配合 update_plugins_{$hostname} 过滤器,就能让私有插件走自己的更新源,体验和官方插件一模一样——后台照样有更新提示、照样一键更新、照样有 changelog。
原理分析:更新检查的完整链路
WordPress 的插件更新检查流程:
- 每 12 小时(或手动点击「检查更新」),WordPress 把所有已安装插件的信息打包,请求
api.wordpress.org/plugins/update-check/1.1/; - 官方返回有更新的插件列表,结果存进
update_plugins这个 site transient(12 小时); - 后台「仪表盘 → 更新」和「插件」页从这个 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
常见踩坑
-
插件头没有
Update URI:私有插件被当成 wp.org 插件送去官方检查,slug 撞车时会收到别人的更新提示,甚至被覆盖安装。 -
返回数组里少了
package:后台显示「有更新」,点更新报「Download failed」。 -
案例: 某团队的更新源返回 JSON 用了
new_version字段(方式 B 的对象格式),但客户端用的是方式 A(数组格式,字段应叫version)。结果后台永远显示「已是最新」。排查方式是wp eval 'print_r( get_site_transient("update_plugins")->response );'直接看 transient 里到底存了什么——两种格式的字段名不同,这是最常踩的坑。 -
更新检查太频繁:
wp_update_plugins有 12 小时缓存,手动调试时记得wp transient delete update_plugins,否则改了半天看不到效果。 -
zip 包目录结构不对:包解压后必须是
mmf-toolbox/mmf-toolbox.php这样的「一层目录 + 主文件」,直接把文件放在 zip 根目录下会更新失败。 -
没有
Requires PHP头:客户端 PHP 版本不满足时,后台会直接隐藏更新提示,让人以为更新源坏了。 -
更新包 URL 用 http:现代 WordPress 对非 HTTPS 的下载会拒绝,报「下载失败」。
-
忘了处理请求失败:
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。
