插件发版前最后一晚,我打印了这张"四色标记纸"贴在显示器上:权限、路由、菜单、配置逐项过
去年有回凌晨两点发版,早上七点被用户钉钉炸醒——超级管理员能看到"数据清理"菜单,点进去却报 403。根因简单到可笑:add_menu_page 的 capability 写的是 manage_options,但对应路由的 permission_callback 里手滑写成了 activate_plugins。同一个功能,菜单和路由各认各的权限字串,用户刚好卡在两者的交集缝隙里。
自那以后我搞了张实体清单,发版前逐项打钩,分成四栏用不同颜色马克笔标。不是迷信流程,是大脑在凌晨真的会短路。
🔴 权限栏:别信"看起来对",要穷举角色矩阵
我的做法是用一段一次性脚本把所有角色能力摊平对比,而不是肉眼扫代码:
// 发版前扔进 wp-cli 或临时插件里跑一遍
$roles = wp_roles()->roles;
$my_cap = 'myplugin_view_analytics';
$screens = ['dashboard', 'settings', 'tools'];
foreach ($roles as $slug => $data) {
$role = get_role($slug);
$has_cap = $role ? $role->has_cap($my_cap) : false;
printf(
"[%s] %s: %s\n",
$has_cap ? '✓' : '✗',
str_pad($slug, 20),
implode(', ', array_filter($screens, fn($s) =>
// 你的实际鉴权逻辑
apply_filters("myplugin_screen_{$s}_cap", $my_cap, $role)
))
);
}
重点不是代码本身,是强制自己列出所有角色。自定义角色、会员插件追加的角色、多站点里的超级管理员映射,都要过一遍。我踩过的坑包括:忘了 shop_manager 这类 Woo 注入的角色,或者多站点里 is_super_admin() 和 current_user_can('manage_network') 在单站点场景下的表现差异。
🟡 路由栏:REST 和 Admin-Ajax 分开验,别混为一谈
REST 路由我现在的习惯是注册完立刻用 rest_do_request 做一轮"自噬测试":
// 在单元测试或临时调试文件里
$request = new WP_REST_Request('GET', '/myplugin/v1/stats');
$request->set_param('period', '7d');
// 未登录
wp_set_current_user(0);
$response = rest_do_request($request);
assert($response->get_status() === 401, '匿名访问应被拒');
// 订阅者
wp_set_current_user(get_user_by('login', 'subscriber_test')->ID);
$response = rest_do_request($request);
assert($response->get_status() === 403, '低权限应被拒');
Admin-Ajax 那边更阴,因为 DOING_AJAX 为真时有些权限检查会短路。我单独列一条:所有 wp_ajax_* 和 wp_ajax_nopriv_* 的 handler,第一行必须是显式的 check_ajax_referer 或 current_user_can,不能依赖外层钩子"顺便"拦住。
🟢 菜单栏:slug 撞车比想象中常见
有次我的"用户画像"菜单把另一个插件的"用户画像"顶掉了,两边 add_menu_page 的 slug 都是 user-profile,后加载的覆盖先加载的。现在我的清单里有条硬性规则:所有菜单 slug 必须带插件前缀,且发版前跑 $GLOBALS['menu'] 和 $GLOBALS['submenu'] 的全局扫描,检查冲突。
add_action('admin_menu', function () {
global $menu, $submenu;
$my_slugs = array_column($menu, 2);
$my_prefix = 'myplugin_';
foreach ($my_slugs as $slug) {
if (strpos($slug, $my_prefix) !== 0 && in_array($slug, ['myplugin_dashboard'])) {
// 实际是自己注册的,这里只是示例检查逻辑
error_log("菜单 slug 缺少前缀: {$slug}");
}
}
}, 999); // 最后跑,确保所有菜单都已注册
子菜单的 parent slug 拼错也是经典失误,比如 add_submenu_page('myplugin-dashbord', ...) 少了个 a,结果挂到顶层菜单外面变成孤儿项。这种 typo 肉眼很难抓,我现在的做法是把所有 slug 抽成常量,IDE 自动补全 + 编译期检查。
🔵 配置栏:默认值、序列化、缓存失效三连
配置项我分三类检查:
默认值有没有漏? 不是指代码里的 $default = '',是指用户从未保存过配置页时的行为。我习惯在全新安装的沙箱里,不点保存直接看前端表现,这时候很多 get_option('myplugin_setting') 返回 false,而代码预期的是数组或特定字符串。
序列化数据能不能降级? 如果某个版本把配置结构从扁平改成了嵌套数组,老用户升级后 get_option 拿到的是旧结构,新代码直接数组访问会炸。我现在在 register_activation_hook 和 upgrader_process_complete 里都塞了结构迁移,且迁移逻辑要支持"从任意历史版本跳到当前版本",不能假设用户是逐个版本升级的。
缓存有没有背刺? 最狠的一次是我用 update_option 后立即 wp_redirect,结果 object cache 没回写,页面刷新显示的还是旧值。现在配置保存后我强制加一行 wp_cache_delete('myplugin_settings', 'options');,哪怕看起来冗余。如果用了自定义 cache group,还要确认 group 名没写错——我查过两小时才发现把 myplugin 写成了 myplugins。
清单的物理形态
我说"打印"是真的打印。A4 纸四栏,每栏下面留空白手写备注。电子版 checklist 容易复制粘贴全选通过,手写勾强迫你逐条过脑子。发版前最后一晚,我对着纸逐项念出声,权限栏念角色名,路由栏念状态码,菜单栏念 slug,配置栏念默认值。
有回念到配置栏,突然发现某个新加的 sanitize_callback 里用了 absint,但那个字段实际存的是带小数的费率。要是没这张纸,凌晨发版、早上报错、白天救火的三部曲又要重演。
你们发版前有什么"笨办法"能拦住低级失误?我这张纸迭代了六七版,感觉还能再补几栏。

