QiLog 插件开发手册
插件是 QiLog 的扩展方式:不改核心代码,就能改前台内容、往后台加页面、注册前台路由、建自己的数据表。
机制是 WordPress 风格的过滤器 + 动作,只有四个 API;扩展点(前台内容、数据写入、后台、媒体、路由、组件、生命周期事件)约 20 个,完整清单见本册 §6。插件目录 data/plugins/ 不参与 autoload,只有已启用的插件才会在每次请求启动时被 require_once 一次。
配套还有《主题开发手册》和《用户手册》。
1. 插件是什么
一个插件就是一个目录。目录里有:
plugin.json—— 声明元信息与后台设置面板结构;bootstrap.php—— 入口,启用后随应用启动加载一次;所有钩子在这里注册。
没有编译、没有 Composer、没有构建步骤。插件和核心共用同一个 PHP 进程、同一个容器、同一个数据库连接。
插件能做的事(全部通过扩展点):
| 能力 | 靠什么 |
|---|---|
| 改前台内容(正文 / 摘要 / 评论 / 列表) | post_content / post_excerpt / comment_text / front_posts |
| 往页面注入 HTML / JS | theme_head / theme_footer |
| 接管评论开关 | post_allow_comment |
| 注册前台公开页面 | front_register_routes |
| 换掉后台编辑器 | admin_markdown_editor / admin_editor_ext |
| 往后台加页面与菜单 | register_plugin_page / admin_menu_ext |
| 往仪表盘加面板 | admin_dashboard |
| 注册自己的小部件类型 | widget_args_{type} / widget_data_{type} |
| 登记媒体引用源(删除图片前能被发现) | media_reference_sources |
2. 目录与加载时机
data/plugins/<插件名>/
├── plugin.json 声明 + 后台设置面板 schema(必需)
├── bootstrap.php 入口(必需,启用后加载)
├── src/ 其余类 / 业务代码(自行 require,不进 autoload)
├── assets/ 静态资源(经 /assets/plugin/<插件名>/… 分发)
│ └── dist/
└── sql/ 建表语句(可选,配合 bootstrap 顶部自检块)
加载时机:PluginLoader::boot() 按「已启用列表」逐个 require_once 其 bootstrap.php,每个插件一个隔离作用域——bootstrap.php 里的变量不会泄漏到别的插件或核心。
隔离作用域带来一个必须记住的后果:bootstrap.php 里没有 $app、没有 $this。要拿服务(db / options / auth / log …),走容器单例:
$c = \App\Kernel\Container::instance();
$db = $c->get('db');
$opts = $c->get('options');
容器里注册的服务名(在 app/bootstrap.php 中注册):config、log、cache、session、flash、view、router、db、hooks、pluginConfig、auth、options、media、mailer、mailQueue、posts、tp(即 ThemePluginService)、menu、stats、cron、update、upgrade、widgets。
插件里最常用的是:db(数据库)、options(站点配置读写)、auth(当前登录用户与能力判断)、media(媒体库上传与引用)、hooks(钩子系统,极少直接取)、pluginConfig(插件配置)、view(渲染)、tp(主题/插件服务)。其余按需取用。
(例外:register_plugin_page() 的回调由后台分发器调用,会显式传入 $app 容器,见 §8.1。)
两条硬约束:
- 插件自己的类不自动加载。类名不遵循
App\前缀,autoload 找不到。要么在 bootstrap 里手动require_once,要么把逻辑写成闭包。 name必须等于目录名。路由(/admin/ext/{name}、/assets/plugin/{name}/…)与资源分发都按名字找目录。
3. plugin.json 字段
| 字段 | 类型 | 说明 |
|---|---|---|
name |
string | 插件标识,等于目录名 |
title |
string | 显示名 |
version |
string | 插件自身版本,与应用版本无关 |
author |
string | 作者 |
author_url |
string | 作者主页 |
description |
string | 一句话介绍 |
depends |
string[] | 依赖的其他插件名;依赖不满足时启用会被拒 |
settings |
array | 后台设置面板 schema,见下 |
最小可用:
{
"name": "my-plugin",
"title": "我的插件",
"version": "1.0.0",
"author": "你的名字",
"description": "一句话介绍",
"depends": [],
"settings": [
{ "key": "api_key", "label": "API Key", "type": "text", "default": "" }
]
}
settings 元素:key(配置键)、label(标签)、type(控件类型,取值同主题:text / number / select / switch / color / textarea / checkbox)、default(默认值)。
type=select 时可加 fill,做联动批量填充——选中某项后自动把其他配置项填成预设值(ai-tools 插件的「服务商预设」就是这么做的)。
配置存在哪里:统一存在 option plugins 里,是一个 JSON 数组:
[{ "name": "ai-tools", "enabled": 1, "sort": 0, "settings_json": { "api_key": "…" } }]
enabled=1 的才加载。没有独立的 plugins 数据表——插件状态与配置都在这一个 option 里。
depends 声明的依赖会在装载时做拓扑排序:被依赖方总是先装载;依赖未启用 / 不存在时该插件跳过装载并在后台给出告警(不会静默失效)。循环依赖同样被检出并跳过,不影响其它插件。
用户手册里说的「启用插件时会顺带建表」,就是靠下面的生命周期回调(§4.2)实现的。
4. 第一个插件
data/plugins/hello/plugin.json:
{ "name": "hello", "title": "Hello", "version": "1.0.0" }
data/plugins/hello/bootstrap.php:
<?php
// 防止被直接访问:只有加载过核心钩子函数的环境才继续
if (!function_exists('add_filter')) return;
add_filter('post_content', function (string $html, array $post): string {
if (($post['type'] ?? '') !== 'post') return $html;
return $html . '<p>—— 由 hello 插件附加</p>';
}, 10, 2);
add_action('theme_footer', function (): void {
echo '<script src="/assets/plugin/hello/dist/app.js" defer></script>';
});
到后台「插件」页点「启用」。此后每个请求启动时都会执行一次 bootstrap.php,钩子就位。
4.2 生命周期回调(建表就放这里)
在 bootstrap.php 顶层声明下面这三个约定名字的全局函数,宿主会在对应时机调用它们(未声明则静默跳过,回调抛异常不会阻断启用/停用流程):
| 函数名 | 触发时机 | 典型用途 |
|---|---|---|
ActivePlugin_{id}() |
启用 / 安装完成后 | 建表、初始化默认配置 |
DeactivatePlugin_{id}() |
停用 / 删除前 | 清理自建资源 |
UpgradePlugin_{id}() |
覆盖升级后 | 数据结构迁移 |
{id} = 插件目录名统一转小写、非 [a-z0-9_] 的字符替换为 _。目录名 my-plugin → my_plugin:
function ActivePlugin_my_plugin(): void {
$db = \App\Kernel\Container::instance()->get('db');
$table = $db->prefix() . 'hello_log';
// 幂等建表(用 information_schema 查存在性,别用 SHOW COLUMNS)
$exists = (bool) $db->value(
"SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?",
[$table]
);
if (!$exists) {
$db->query("CREATE TABLE `{$table}` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`message` VARCHAR(255) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
}
为什么生命周期回调必须存在:停用/启用插件的后台入口本身依赖插件可用。如果缺表导致插件抛错,用户就再也点不了「停用」——死锁。所以建表要发生在「启用」这个明确时机,而不是运行时的每次请求。
系统在启用 / 安装插件后还会额外跑一次 php app/cli/plugin-setup.php <插件名> 作为双保险(它以伪 Cron 上下文装载 bootstrap,从而跳过请求驱动的兜底任务,避免子进程产生写库副作用;任何失败都静默 exit 0,不阻断启用)。
统一生命周期事件动作:除了上面的函数式回调,宿主在启用 / 停用 / 升级后还会广播三个 action——
plugin_activated/plugin_deactivated/plugin_upgraded(参数均为插件名$name)。其他插件可add_action()监听这些事件做联动,不要反向依赖某插件的ActivePlugin_{id}函数(那只是该插件自身初始化入口,别的插件不应直接调用)。⛔ 绝不要在
bootstrap.php的顶层做重活或结构探测。bootstrap.php在每一个请求上都会执行;在里面跑SHOW COLUMNS/DESCRIBE/ALTER TABLE既慢又危险(这是本项目明确的红线)。只有ActivePlugin_*/DeactivatePlugin_*/UpgradePlugin_*这三个回调是「明确时机」。
5. 钩子机制
5.1 注册 API
四个全局函数(App\Kernel\Hooks 的别名,名字与 WordPress 相同,但只有这四个):
// filter:接收一个值,必须返回修改后的值
add_filter(string $tag, callable $cb, int $priority = 10, int $acceptedArgs = 1): void
// action:只触发副作用,无返回值
add_action(string $tag, callable $cb, int $priority = 10, int $acceptedArgs = 1): void
// 触发(一般由核心调用,插件很少自己调)
apply_filters(string $tag, $value, ...$args)
do_action(string $tag, ...$args)
5.2 filter 与 action 的语义差别
| filter | action | |
|---|---|---|
| 返回值 | 必须返回;不返回则后续回调拿到 null |
无 $value 位,回调第 1 个参数就是 do_action() 的第 1 个实参 |
| 数据流 | 上一个回调的返回值 = 下一个回调的输入 | 无数据流,只执行副作用 |
| 典型用途 | 改内容、改数据集、提供 HTML | 输出脚本、打印面板 |
| 无回调时 | 原值原样返回 | 无副作用 |
最容易踩的一条:action 说的「无 $value 位」,意思是 do_action('theme_footer', $a) 的回调收到的是 ($a),不是 ($value, $a)。别习惯性写成两参。
5.3 优先级与参数个数
priority数字越小越先执行,默认 10;acceptedArgs声明回调接收几个参数。要拿第 2 个参数就必须把acceptedArgs设为 ≥ 2,否则只传 1 个。
add_filter('post_content', function (string $html, array $post): string {
return $html . '<!-- 用过 $post 了 -->';
}, 10, 2); // ← 这个 2 不能省
5.4 动态钩子
名称里带 {...} 的钩子是动态的:注册时把 {} 换成具体名称。
add_filter('widget_data_notice', function ($value, array $args, $renderer) {
return ['text' => '公告内容'];
}, 10, 3);
6. 扩展点全清单(约 20 个)
6.1 一览表
| 名称 | 类型 | 分类 | 说明 |
|---|---|---|---|
theme_footer |
filter | 主题 | </body> 前注入,返回 HTML 原样输出 |
theme_head |
filter | 主题 | </head> 前注入,返回 HTML 原样输出 |
comment_text |
filter | 前台内容 | 评论过滤;纯文本契约,模板输出时会转义 |
front_posts |
filter | 前台内容 | 列表数据集过滤,$ctx['source'] 区分来源 |
post_allow_comment |
filter | 前台内容 | 评论开关接管,返回 bool |
post_content |
filter | 前台内容 | 正文 HTML 过滤,返回 HTML 原样输出 |
post_excerpt |
filter | 前台内容 | 摘要过滤;纯文本契约 |
front_register_routes |
filter | 前台路由 | 注册前台公开路由($value 是 Router 实例) |
admin_dashboard |
action | 后台 | 仪表盘注入区,直接 echo |
admin_editor_ext |
filter | 后台 | 编辑器下方扩展区 HTML |
admin_markdown_editor |
filter | 后台 | 替换后台 Markdown 编辑器 |
admin_menu_ext |
filter | 后台 | 后台侧栏「扩展」组菜单注入 |
api_register_routes |
filter | REST API | 注册 /api/v1/{资源} 扩展端点(自动继承 API 开关 / Bearer 鉴权 / 限流) |
media_reference_sources |
filter | 媒体 | 登记附加媒体引用源 |
widget_args_{type} |
filter | 组件 | 自定义组件参数 schema(动态) |
widget_data_{type} |
filter | 组件 | 自定义组件数据源(动态) |
post_data |
filter | 数据写入 | 文章入库前过滤,$value = 字段数组,$mode = create/update;返回修改后的数组(可增改字段) |
post_save |
action | 数据写入 | 文章入库后触发,($id, $data, $isNew);用于第三方同步、自动处理、SEO |
post_delete |
action | 数据写入 | 文章物理删除后触发,($id);用于级联清理 / 外部同步 |
comment_data |
filter | 数据写入 | 评论入库前过滤,$value = 字段数组;返回修改后的数组 |
comment_save |
action | 数据写入 | 评论入库后触发,($cid, $data);用于反垃圾、通知、外部同步 |
plugin_activated |
action | 生命周期 | 某插件启用 / 安装后广播,($name);供第三方联动 |
plugin_deactivated |
action | 生命周期 | 某插件停用 / 删除前广播,($name) |
plugin_upgraded |
action | 生命周期 | 某插件覆盖升级后广播,($name) |
6.2 主题与前台内容
post_content —— 正文 HTML。$value = (string)$post['content_html'],额外参数 $post。返回的 HTML 原样输出。
⛔ 正文两列契约:
content_html恒有值;content_md只装 Markdown 原文,由富文本编辑器(如 wangeditor)创作的文章里它是空串。 判断正文形态:content_md非空 ⇒ Markdown 文章;为空 ⇒ 富文本文章(正文只在content_html)。 不要假设content_md一定有内容,也不要往它里面写 HTML(旧版 wangeditor 曾把同一份 HTML 同时写进两列,自其 1.1.0 起已停止)。
post_excerpt —— 摘要。纯文本契约:返回富文本会变成可见标签。
comment_text —— 评论。同样纯文本契约。
front_posts —— 列表数据集。追加参数 ['source' => 'home'|'category'|'tag'|'search'|'archive']。改条目数组即可;增删条目要自己保证分页一致。
add_filter('front_posts', function (array $items, array $ctx): array {
if (($ctx['source'] ?? '') !== 'home') return $items;
foreach ($items as &$it) { $it['title'] = '★ ' . $it['title']; }
return $items;
}, 10, 2);
post_allow_comment —— 评论开关。$value 是当前开关(bool),额外参数 $post。返回 false 即关闭该篇评论。
theme_head / theme_footer —— 注入区。注意:这两个也是 filter,触发器形如 <?= apply_filters('theme_footer', '') ?>,所以要 return 字符串,不能只 echo。
6.3 前台路由
front_register_routes —— $value 是 Router 实例,可用 get / post / any 注册,回调必须原样返回 $r,否则后续注册全丢。
add_filter('front_register_routes', function ($r) {
$r->get('/wall', function () {
return '<h1>留言墙</h1>';
});
$r->post('/wall/submit', function () {
// 自己处理 CSRF 与入库
return redirect(url('wall'));
});
return $r; // ← 必须
});
回调返回的字符串直接 echo(与核心路由一致)。路由注册顺序敏感:更具体的路径必须写得比兜底路由早;插件注册发生在核心之后,所以自定义路径不要与核心 /{slug} 冲突。
⚠️
front_register_routes必须用add_filter,不要用do_action。 核心调用的是apply_filters('front_register_routes', $router),会把$router作为第一个参数传入并接收返回值。若误写成do_action('front_register_routes', $router),内核的Hooks::run()会在回调前面预置一个$value = null实参,导致你的回调拿到的$r是null,随后$r->get(...)直接抛Fatal error: Call to a member function get() on null。同理,凡核心用apply_filters派发的钩子,插件侧一律用add_filter并在末尾return修改后的值;凡核心用do_action派发的钩子(如admin_dashboard、plugin_activated),插件侧一律用add_action且不需要返回。
6.3.1 REST API 扩展
api_register_routes —— 把插件自有数据暴露为 /api/v1/{资源}。$value 是资源映射数组;回调签名 function($app, array $sub, $guard, string $method),返回 JSON 字符串。
add_filter('api_register_routes', function (array $map): array {
$map['myplug'] = function ($app, array $sub, $guard, string $method) {
if ($method !== 'GET') {
return \App\Http\Api\ApiResponse::error(405, 'method_not_allowed', '仅支持 GET');
}
if (!$guard->authenticated()) {
return \App\Http\Api\ApiResponse::error(401, 'unauthorized', '需要令牌');
}
return \App\Http\Api\ApiResponse::json(['hello' => $sub[0] ?? 'world']);
};
return $map;
});
- 自动继承站点「设置 → API」的开关、限流与每用户令牌上限。
$guard提供authenticated()/canCap()/isReadOnly()/user()。- 公开端点请做字段投影,避免泄漏邮箱 / IP 等 PII。
- 完整端点与响应包裹见
docs/rest-api-guide.md第 7 节;可运行示例data/plugins/apidemo/。
6.4 后台
register_plugin_page() —— 见 §8.1。
admin_menu_ext —— 往侧栏「扩展」组注入菜单项。见 §8.2。
admin_dashboard —— 仪表盘注入区,action,无参数,直接 echo:
add_action('admin_dashboard', function (): void {
echo '<div class="panel"><div class="panel-head"><span class="title">我的面板</span></div>'
. '<div class="panel-body">内容</div></div>';
});
admin_markdown_editor —— 换掉后台文章 / 单页编辑器。参数为 ($defaultHtml, $post, $isPage):
add_filter('admin_markdown_editor', function (string $html, array $post, bool $isPage): string {
return '<textarea id="my-editor" name="content">' . e($post['content'] ?? '') . '</textarea>'
. '<script src="/assets/plugin/my-editor/dist/editor.js" defer></script>';
}, 10, 3);
换编辑器后表单字段名必须仍然叫
content,否则保存拿不到内容。
admin_editor_ext —— 编辑器下方的扩展区。
6.5 媒体
media_reference_sources —— 登记你自己的表里对媒体文件的引用,让「删除图片」前能检查到占用:
add_filter('media_reference_sources', function (array $srcs): array {
$srcs[] = ['table' => '{prefix}my_table', 'column' => 'cover', 'where' => 'status = 1'];
return $srcs;
});
table里可用{prefix}占位(会被替换成真实表前缀);where可选,追加过滤条件。
6.6 组件(动态)
widget_args_{type} —— 声明你的组件类型接受哪些参数(后台会据此生成表单)。
add_filter('widget_args_notice', function (array $value, string $type): array {
return [
['key' => 'text', 'label' => '公告内容', 'type' => 'textarea', 'default' => ''],
];
}, 10, 2);
widget_data_{type} —— 提供数据。$value 初始为 null,返回数组即生效,返回 null 视为「无此组件」(不会渲染)。
add_filter('widget_data_notice', function ($value, array $args, $renderer) {
return ['text' => (string)($args['text'] ?? '')];
}, 10, 3);
数据到位后,渲染走片段 app/resources/widgets/notice.php(核心内置目录)或 <主题>/widgets/notice.php。片段拿得到 $title、$widget 与你返回的数据键。
6.7 数据写入钩子(文章与评论)
上面 §6.2 都是「改已有内容」,这一组是「在内容落库前后介入」,适合做关联数据、字段增强、第三方同步:
post_data —— 文章入库前。$value = $data(字段数组),$mode = 'create' | 'update'。返回修改后的数组即可给文章加字段:
add_filter('post_data', function (array $data, string $mode): array {
if ($mode === 'create') {
$data['extra'] = '默认值';
}
return $data;
}, 10, 2);
不要对
$data做整表写操作;这里只是「增改字段后返回」。批量更新由核心的PostService负责。
post_save / post_delete —— 入库后 / 删除后,拿不到返回值,只做副作用(同步、统计、清理):
add_action('post_save', function (int $id, array $data, bool $isNew): void {
// 例如:把摘要同步到外部搜索、累加作者文章数
}, 10, 3);
comment_data / comment_save —— 评论同理。典型用法:评论入库后调反垃圾、发通知;或拦截(comment_data 里把状态改成待审)。
add_filter('comment_data', function (array $data): array {
if (empty($data['author'])) { $data['author'] = '匿名'; }
return $data;
});
7. 插件配置
plugin.json 的 settings 会在后台「插件 → 设置」生成面板。读取:
$key = get_plugin_config('my-plugin', 'api_key', '');
$all = get_plugin_configs('my-plugin');
配置在 bootstrap.php 加载之前就已就绪(PluginConfig 先载入后 PluginLoader 才 boot),所以闭包里随时能读,不需要延迟初始化。
select 项的 fill 用法(联动填充):
{
"key": "provider", "label": "服务商", "type": "select", "default": "a",
"options": { "a": "服务商 A", "b": "服务商 B" },
"fill": { "a": { "endpoint": "https://a.example.com/v1" },
"b": { "endpoint": "https://b.example.com/v1" } }
}
8. 后台扩展页
8.1 register_plugin_page
register_plugin_page(
'my-page', // 路由名 → /admin/ext/my-page
function ($ctrl, $isPost, $action, $app) {
// $ctrl AdminController 实例
// $isPost 是否 POST
// $action 路径里的 {action} 段(无则 ''),可据此区分页面与 AJAX
// $app 容器,取服务用:$app->get('options') / $app->get('db')
if ($action === 'ping') {
header('Content-Type: application/json; charset=utf-8');
return json_encode(['ok' => true]);
}
return '<div class="panel">我的页面</div>';
},
5, // 最低角色等级 1–5(5 = 管理员)
'我的插件', // 侧栏菜单标签
'puzzle' // 菜单图标名
);
| 参数 | 说明 |
|---|---|
| 1 路由名 | 拼成 /admin/ext/{name} 与 /admin/ext/{name}/{action} |
| 2 回调 | 签名 ($ctrl, $isPost, $action, $app),返回字符串(自动套后台 layout);参数可少声明 |
| 3 最低等级 | 1 订阅者 … 5 管理员,见 §14 |
| 4 菜单标签 | 后台侧栏「扩展」组里的文字 |
| 5 菜单图标 | 图标名(layout_top.php 的图标集) |
分发者是 AdminController::pluginPage(),它会再走一次等级门禁 + CSRF 校验,然后调用回调。所以你的回调不需要自己做权限判断,但仍要自己防注入。
返回 JSON 时记得先输出 JSON 响应头——fetch().json() 消费的端点必须是 JSON。
8.2 admin_menu_ext
想加不走这套分发机制的后台入口,用过滤器往侧栏注入(消费位置 app/resources/admin/layout_top.php):
add_filter('admin_menu_ext', function (array $items): array {
$items[] = ['id' => 'ext-demo', 'label' => '我的入口',
'icon' => 'file', 'href' => 'admin/ext/my-page', 'cap' => 5];
return $items;
});
数组项契约:id / label / icon / href / cap。href 是不带域名也不带前导斜杠的相对路径(admin/ext/my-page),核心会用 url() 拼。cap 是所需最低等级;不声明则默认仅管理员可见。
8.3 路由命名
/admin/ext/ 是刻意独立的前缀,为的是避开 /admin/plugin/{action}/{name}(插件的启用 / 停用 / 设置)。别再往 /admin/plugin/ 下加东西。
9. 静态资源分发
插件资源不在站点目录直出——项目根目录根本没有 assets/plugin/ 这个物理目录。请求由 AssetController::plugin() 分发:
浏览器 /assets/plugin/my-plugin/dist/app.js
↓
磁盘 data/plugins/my-plugin/assets/dist/app.js
安全与缓存:
- 带路径穿越防护:
realpath结果必须落在该插件的assets/目录内,否则拒绝; - MIME 映射表按扩展名下发
Content-Type; - 响应头
Cache-Control: public, max-age=86400。
模板里拼地址:
$base = rtrim(base_url(), '/') . '/assets/plugin/my-plugin';
Nginx 上必须加 ^~ /assets/ 例外,否则资源 404、编辑器空白:
location ^~ /assets/ {
try_files $uri $uri/ /index.php?$query_string;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
原因:宝塔默认站点配置带一条匹配静态扩展名的正则 location(如 location ~ .*\.(js|css)?$),正则 location 优先级高于普通前缀 location,且不带 try_files 兜底,会直接按磁盘路径取文件 → 404 → 请求根本到不了 index.php。^~ 让前缀匹配一旦命中就跳过所有正则。
诊断:
curl -I https://你的域名/assets/plugin/my-plugin/dist/app.js
200 + Content-Type: …javascript 才算对。
10. 数据库与建表
10.1 约定
- 表前缀用
Db::prefix()取,别写死qilog_; - 一切落库走
Db的参数化查询,禁止字符串拼接 SQL; Db::insert($table, [])空数组会抛异常;update/delete空条件也会抛异常(这是有意的防呆);insert/update/delete/count自动加表前缀;query/fetchAll/value需要自带前缀;$db->table()返回 QueryBuilder,不能拼进 SQL 字符串。
10.2 建表时机:启用时一次,不是每次请求
建表 SQL 放生命周期回调 ActivePlugin_{id}()(见 §4.2)。这是唯一的正确位置:
| 位置 | 评价 |
|---|---|
ActivePlugin_{id}() |
✅ 启用/安装时跑一次,时机明确 |
bootstrap.php 顶层 |
❌ 每个请求都跑。写重活或结构探测会拖慢全站,且是本项目明令禁止的红线 |
| 某个插件页面首次打开时 | ⚠️ 能工作,但「停用入口依赖插件可用」的死锁风险仍在 |
系统在启用 / 安装后还会额外跑一次 php app/cli/plugin-setup.php <插件名> 兜底。该脚本以伪 Cron 上下文($_GET['cron']=1)装载 bootstrap,从而跳过请求驱动的兜底任务,避免子进程产生写库副作用;任何失败都静默 exit 0——建表是增强,不该阻断插件启用。
如果不需要关系查询,用文件更划算。 ai-tools 插件 1.4.1 之后就把用量统计从表改成了逐行 JSON 文件(usage.jsonl),理由是不给用户的数据库增加负担,也不必关心建表时机与迁移。插件目录本身可写,这是完全合法的选择。
10.3 结构化变更
这套系统没有迁移机制:app/Migrations/schema.php 只在安装时建表,改它不会同步到已装站点。
对插件自己的表,迁移责任在你:
- 新装 / 启用 →
ActivePlugin_{id}()建全量表结构; - 老版本升级 → 在
UpgradePlugin_{id}()里做增量ALTER TABLE(判断列是否存在用information_schema,不要用SHOW COLUMNS); - 两个回调都必须是幂等的——它们可能被执行多次。
function UpgradePlugin_my_plugin(): void {
$db = \App\Kernel\Container::instance()->get('db');
$table = $db->prefix() . 'hello_log';
$has = (bool) $db->value(
"SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
[$table, 'source']
);
if (!$has) {
$db->query("ALTER TABLE `{$table}` ADD COLUMN `source` VARCHAR(32) NOT NULL DEFAULT ''");
}
}
内核自己的表不归插件管。要给核心内容加字段,走内核的发版流程(改
schema.php+Upgrader::steps()新开版本号),不要由插件去ALTER核心表。表名要拼进 DDL 时必须先做白名单校验(
^[A-Za-z0-9_]+$)——DDL 不支持参数化占位符。表名来自Db::prefix()常量拼接,可控。
11. 自定义小部件类型
见 §6.6。完整链路是:
plugin.json不必声明;在bootstrap.php里用widget_args_{type}声明参数 schema;- 用
widget_data_{type}提供数据(返回null= 无此组件); - 提供渲染片段
app/resources/widgets/{type}.php(或引导用户主题建<主题>/widgets/{type}.php); - 主题在
default_widgets或后台「主题 → 小部件」里选这个类型。
12. 媒体引用登记
见 §6.5。作用是:用户删图片时,系统能提示「这张图正在被 X 表 Y 列使用」,而不是默默删掉导致前台裂图。
13. 安全红线
- filter 返回的 HTML 会原样输出(
post_content、theme_head、theme_footer等)。自己保证内容可信,别把未净化的用户输入拼进去。 comment_text/post_excerpt是纯文本契约:模板输出时会 HTML 转义,返回富文本会变成可见标签。- 落库一律参数化,表前缀用
Db::prefix()。 - 后台页回调仍要自己防 CSRF:
pluginPage()已做等级门禁 + CSRF 校验,但你的 AJAX 子动作如果绕过它,就得自己校验。 fetch().json()消费的写操作必须走 JSON 端点;CSRF 失败要显式返回 400 + JSON 响应头,不要返回 HTML 错误页(前端json()会解析失败、错误被静默吞掉)。- 不要用
SHOW COLUMNS/DESCRIBE做运行时结构探测(见 §10.2)。 - 不要往
refs/、.git/之类的地方写文件——这不是插件该碰的区域。
14. 权限等级
五级角色(Auth):
| 等级 | 角色 | 展示名 |
|---|---|---|
| 1 | subscriber | 订阅者 |
| 2 | contributor | 投稿者 |
| 3 | author | 作者 |
| 4 | editor | 编辑 |
| 5 | admin | 管理员 |
register_plugin_page() 的第 3 个参数与 admin_menu_ext 项里的 cap 都用这套等级(整数 1–5)。
权限有两套,别混:
- 角色等级(1–5):上面这张表,控制「谁能进这个后台页 / 看这个菜单」。pluginPage() 已替你做等级门禁。
- 能力(CAPS 字符串):如 media.manage、post.manage、comment.manage,控制「能做什么具体操作」。Auth::canCap('media.manage') 返回 bool,自定义 AJAX 端点要自己用它对写操作做真防护——前端的菜单可见性、甚至等级门禁,都只是体验,真正拦未授权写操作的是你自己的 canCap 判断。
function myGuard(\App\Kernel\Container $app): ?array {
$auth = $app->get('auth');
if (!$auth->isAdmin() && !$auth->canCap('media.manage')) return null; // 拦下
return ['ok' => true];
}
15. 打包、安装与分发
后台「插件 → 上传插件」接受 zip,校验规则与主题一致(ThemePluginService::installFromZip()):
- zip 只含一个顶层目录,目录名即插件名(
^[a-zA-Z0-9_-]+$); - 拒绝
../ 绝对路径 / 盘符条目; - 解压到临时目录 → 校验
plugin.json(或.yaml) → 搬到data/plugins/<name>/; - 同名先删旧版(覆盖安装);
- 已启用的插件不能被覆盖,会提示先停用。
还需要 ZipArchive(PHP zip 扩展)——未启用时后台会明确提示去开 extension=php_zip.dll。
打包示例:
cd data/plugins
zip -r my-plugin.zip my-plugin -x "*.DS_Store" "*/.git/*" "*.bak*"
16. 抄得走的配方
16.1 每篇文章尾部加版权声明
add_filter('post_content', function (string $html, array $post): string {
return $html . '<div class="post-notice">转载请注明出处。</div>';
}, 10, 2);
16.2 全站注入统计脚本
add_filter('theme_footer', function (string $html): string {
return $html . '<script defer src="https://example.com/stat.js"></script>';
});
注意是 filter:要
return,不能只echo。
16.3 往 <head> 加 meta
add_filter('theme_head', function (string $html): string {
return $html . '<meta name="theme-color" content="#3082AB">';
});
16.4 换掉后台 Markdown 编辑器
见 §6.4。字段名保持 content。
16.5 批量改写首页列表标题
add_filter('front_posts', function (array $items, array $ctx): array {
if (($ctx['source'] ?? '') !== 'home') return $items;
foreach ($items as &$it) { $it['title'] .= ' ·'; }
return $items;
}, 10, 2);
16.6 后台加一个带 AJAX 的页面
register_plugin_page('my-tools', function ($ctrl, $isPost, $action, $app) {
if ($action === 'run') {
header('Content-Type: application/json; charset=utf-8');
return json_encode(['ok' => true, 'time' => date('c')]);
}
return '<div class="panel"><div class="panel-body">'
. '<button class="btn btn-primary" onclick="run()">执行</button>'
. '<script>function run(){fetch("' . url('admin/ext/my-tools/run') . '",{method:"POST",'
. 'headers:{"X-Requested-With":"fetch"},body:new FormData(document.querySelector("form"))})'
. '.then(r=>r.json()).then(d=>alert(d.time));}</script>'
. '</div></div>';
}, 5, '我的工具', 'puzzle');
16.7 注册一个前台 JSON 接口
add_filter('front_register_routes', function ($r) {
$r->get('/api/hello', function () {
header('Content-Type: application/json; charset=utf-8');
return json_encode(['ok' => true, 'time' => time()]);
});
return $r;
});
16.8 把某篇文章的评论强制关掉
add_filter('post_allow_comment', function (bool $open, array $post): bool {
return (int)($post['id'] ?? 0) === 42 ? false : $open;
}, 10, 2);
17. 踩坑与雷区
| 现象 | 原因 / 处理 |
|---|---|
| 钩子完全不生效 | 插件没启用;或 bootstrap.php 第一行守卫 if (!function_exists('add_filter')) return; 之外还有语法错误被静默吞掉。开 debug 看日志 |
| 回调收不到第 2 个参数 | 忘了声明 acceptedArgs(add_filter($tag, $cb, 10, 2)) |
| filter 之后内容变成空 | 回调没有 return(filter 不返回值 → 下一个拿到 null) |
theme_head / theme_footer 里 echo 了却没输出 |
这两个是 filter,要 return 字符串 |
| 前台路由整个失效 | front_register_routes 回调忘了 return $r |
| 插件资源 404 | Nginx 缺 ^~ /assets/,见 §9 |
| 换编辑器后保存丢内容 | 表单字段名不再是 content |
| 后台页报 500 / 白屏 | 回调抛异常。pluginPage() 不做 try/catch 兜底,先在 debug 下看堆栈 |
| 启用插件时卡死或报错 | bootstrap 顶部在建表 / 做重活。建表只在启用时机跑一次(§10.2),运行时别再探测结构 |
| 插件之间互相影响 | bootstrap.php 是隔离作用域,但类和函数名是全局的。类加前缀、函数用闭包 |
| 覆盖安装被拒 | 插件正启用,先停用 |
对应 QiLog v1.8.0。文档与代码不一致时以代码为准。
