文档

QiLog 插件开发手册

插件是 QiLog 的扩展方式:不改核心代码,就能改前台内容、往后台加页面、注册前台路由、建自己的数据表。

机制是 WordPress 风格的过滤器 + 动作,只有四个 API;扩展点(前台内容、数据写入、后台、媒体、路由、组件、生命周期事件)约 20 个,完整清单见本册 §6。插件目录 data/plugins/ 不参与 autoload,只有已启用的插件才会在每次请求启动时被 require_once 一次。

配套还有《主题开发手册》和《用户手册》。


1. 插件是什么

一个插件就是一个目录。目录里有:

没有编译、没有 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。)

两条硬约束:

  1. 插件自己的类不自动加载。类名不遵循 App\ 前缀,autoload 找不到。要么在 bootstrap 里手动 require_once,要么把逻辑写成闭包。
  2. 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 优先级与参数个数

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;
});

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;
});

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

安全与缓存:

模板里拼地址:

$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 约定

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 只在安装时建表,改它不会同步到已装站点。

对插件自己的表,迁移责任在你:

  1. 新装 / 启用 → ActivePlugin_{id}() 建全量表结构;
  2. 老版本升级 → 在 UpgradePlugin_{id}() 里做增量 ALTER TABLE(判断列是否存在用 information_schema,不要用 SHOW COLUMNS);
  3. 两个回调都必须是幂等的——它们可能被执行多次。
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。完整链路是:

  1. plugin.json 不必声明;在 bootstrap.php 里用 widget_args_{type} 声明参数 schema;
  2. 用 widget_data_{type} 提供数据(返回 null = 无此组件);
  3. 提供渲染片段 app/resources/widgets/{type}.php(或引导用户主题建 <主题>/widgets/{type}.php);
  4. 主题在 default_widgets 或后台「主题 → 小部件」里选这个类型。

12. 媒体引用登记

见 §6.5。作用是:用户删图片时,系统能提示「这张图正在被 X 表 Y 列使用」,而不是默默删掉导致前台裂图。


13. 安全红线

  1. filter 返回的 HTML 会原样输出(post_content、theme_head、theme_footer 等)。自己保证内容可信,别把未净化的用户输入拼进去。
  2. comment_text / post_excerpt 是纯文本契约:模板输出时会 HTML 转义,返回富文本会变成可见标签。
  3. 落库一律参数化,表前缀用 Db::prefix()。
  4. 后台页回调仍要自己防 CSRF:pluginPage() 已做等级门禁 + CSRF 校验,但你的 AJAX 子动作如果绕过它,就得自己校验。
  5. fetch().json() 消费的写操作必须走 JSON 端点;CSRF 失败要显式返回 400 + JSON 响应头,不要返回 HTML 错误页(前端 json() 会解析失败、错误被静默吞掉)。
  6. 不要用 SHOW COLUMNS / DESCRIBE 做运行时结构探测(见 §10.2)。
  7. 不要往 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()):

  1. zip 只含一个顶层目录,目录名即插件名(^[a-zA-Z0-9_-]+$);
  2. 拒绝 .. / 绝对路径 / 盘符条目;
  3. 解压到临时目录 → 校验 plugin.json(或 .yaml) → 搬到 data/plugins/<name>/;
  4. 同名先删旧版(覆盖安装);
  5. 已启用的插件不能被覆盖,会提示先停用。

还需要 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。文档与代码不一致时以代码为准。

© 2026 QiLog · 基于开源协议发布 · 返回官网首页