插件开发

CnLog 采用钩子驱动的插件架构,通过 HookManager 管理动作钩子(Action)和过滤器钩子(Filter),插件以独立目录形式存在,通过 plugin.json 声明元信息,hooks.json 注册钩子回调。

一、插件体系概述

1.1 核心机制

机制说明相关函数
动作钩子在特定时机执行回调,不修改数据doAction() / addAction() / removeAction()
过滤器钩子拦截并修改数据,返回修改后的值applyFilter() / addFilter() / removeFilter()
生命周期回调激活/停用/卸载时执行特定逻辑在 plugin.json 中声明 callback 函数
资源注入通过钩子注入 CSS/JS 到页面index_head / footer_after 等钩子

1.2 插件加载流程

系统启动 → HookManager::init()
    → 从 app_extensions 表查询 is_enabled = 1 的插件(表前缀可在安装时自定义)
    → 遍历每个已启用插件:
        1. require_once plugin.php(加载主文件)
        2. 读取 hooks.json
        3. 注册所有 action/filter 钩子到 HookManager
    → 钩子注册完成,等待触发

页面请求 → 执行 doAction('xxx') / applyFilter('xxx')
    → HookManager 按优先级排序执行所有注册的回调

1.3 核心全局函数

// 注册动作钩子
addAction(string $hook, callable $callback, int $priority = 10);

// 触发动作钩子
doAction(string $hook, ...$args);

// 注册过滤器钩子
addFilter(string $hook, callable $callback, int $priority = 10);

// 应用过滤器
$value = applyFilter(string $hook, $value, ...$args);

// 移除钩子
removeAction(string $hook, callable $callback);
removeFilter(string $hook, callable $callback);

// 检查钩子是否有注册
hasAction(string $hook): bool;
hasFilter(string $hook): bool;

二、目录结构规范

your_plugin/
├── plugin.json              # 【必须】插件元信息声明
├── hooks.json               # 【必须】钩子注册配置
├── plugin.php               # 【必须】插件主入口文件
├── admin_settings.php       # 【可选】后台设置页面模板
├── README.md                # 【推荐】插件说明文档
├── config/
│   ├── config.default.php   # 【推荐】默认配置
│   └── config.php           # 【可选】运行时配置(自动生成)
├── includes/
│   ├── YourClass.php        # 【推荐】核心业务类
│   └── helpers.php          # 【可选】辅助函数
├── migrations/
│   └── 001_init.sql         # 【可选】数据库迁移脚本
├── templates/
│   └── email_template.html  # 【可选】模板文件
├── assets/
│   ├── css/
│   ├── js/
│   └── images/
└── languages/
    └── zh_CN.php            # 【可选】语言包
文件/目录必需性说明
plugin.json必须插件元信息,包含名称、版本、作者、生命周期回调等
hooks.json必须钩子注册配置,声明插件监听的所有钩子
plugin.php必须插件主入口,包含回调函数和业务逻辑
admin_settings.php可选后台设置页面,has_settings=true 时生效
config/推荐配置文件目录,默认配置与运行配置分离
includes/推荐核心类和辅助函数目录
migrations/可选SQL 数据库迁移脚本
templates/可选插件自有模板文件
assets/可选静态资源(CSS/JS/图片)

三、plugin.json 规范

plugin.json 是插件的元信息声明文件,系统通过它识别插件基本信息和生命周期回调。

3.1 完整示例

{
    "name": "邮件通知插件",
    "slug": "email_notification",
    "version": "1.0.0",
    "description": "为用户注册、订单支付等事件发送 SMTP 邮件通知。",
    "author": "CNLOG Market",
    "author_url": "https://www.cnlog.cc",
    "icon": "fa-envelope",
    "has_settings": true,
    "php_min_version": "7.4",
    "dependencies": [],
    "activation": {
        "sql_file": "install.sql",
        "callback": "email_notification_activate"
    },
    "deactivation": {
        "callback": "email_notification_deactivate"
    },
    "uninstall": {
        "callback": "email_notification_uninstall",
        "drop_tables": []
    }
}

3.2 字段说明

字段类型必需说明
namestring是插件显示名称
slugstring是插件唯一标识,小写+下划线,与目录名一致
versionstring是版本号,语义化版本(如 1.0.0)
descriptionstring是插件功能描述
authorstring是作者名称
author_urlstring否作者主页 URL
iconstring否Font Awesome 图标类名
has_settingsbool否是否有后台设置页(默认 false)
php_min_versionstring否最低 PHP 版本要求
dependenciesarray否依赖的其他插件 slug 列表
activationobject否激活配置:sql_file, callback
deactivationobject否停用配置:callback
uninstallobject否卸载配置:callback, drop_tables

四、hooks.json 规范

hooks.json 声明插件监听的所有钩子,系统启动时自动注册。

4.1 完整示例

[
    {
        "hook": "user_registered",
        "type": "action",
        "priority": 10,
        "callback": "email_notification_on_user_registered"
    },
    {
        "hook": "order_paid",
        "type": "action",
        "priority": 10,
        "callback": "email_notification_on_order_paid"
    },
    {
        "hook": "the_content",
        "type": "filter",
        "priority": 10,
        "callback": "my_plugin_filter_content"
    }
]

4.2 字段说明

字段类型必需说明
hookstring是钩子名称
typestring是钩子类型:action 或 filter
priorityint否优先级,数字越小越先执行,默认 10
callbackstring是回调函数名(必须在 plugin.php 中定义)

五、plugin.php 入口文件

plugin.php 是插件的主入口文件,系统自动加载,必须包含所有在 hooks.json 中声明的回调函数。

5.1 标准结构

<?php
// 安全检查:禁止直接访问
if (!defined('CNLOG_ROOT') && !defined('ROOT_PATH')) {
    exit('Access denied');
}

// 加载依赖文件
require_once __DIR__ . '/includes/YourClass.php';

// ========== 生命周期回调 ==========

function your_plugin_activate() {
    // 激活时执行:创建配置、初始化数据等
    $configFile = __DIR__ . '/config/config.php';
    if (!file_exists($configFile)) {
        copy(__DIR__ . '/config/config.default.php', $configFile);
    }
    return ['success' => true, 'message' => '插件已激活'];
}

function your_plugin_deactivate() {
    // 停用时执行:清理缓存等(一般保留数据)
    return ['success' => true, 'message' => '插件已停用'];
}

function your_plugin_uninstall() {
    // 卸载时执行:删除配置、数据表等
    $configFile = __DIR__ . '/config/config.php';
    if (file_exists($configFile)) {
        unlink($configFile);
    }
    return ['success' => true, 'message' => '插件已卸载', 'deleted_tables' => []];
}

// ========== 配置读写 ==========

function your_plugin_get_config() {
    $configFile = __DIR__ . '/config/config.php';
    if (file_exists($configFile)) {
        return require $configFile;
    }
    return require __DIR__ . '/config/config.default.php';
}

function your_plugin_save_settings(array $settings) {
    $config = your_plugin_get_config();
    $validKeys = ['enabled', 'option1', 'option2'];
    foreach ($validKeys as $key) {
        if (isset($settings[$key])) {
            $config[$key] = $settings[$key];
        }
    }
    $configFile = __DIR__ . '/config/config.php';
    $content = "<?php\nreturn " . var_export($config, true) . ";\n";
    if (file_put_contents($configFile, $content)) {
        return ['success' => true, 'message' => '设置保存成功'];
    }
    return ['success' => false, 'message' => '保存失败'];
}

// ========== 钩子回调函数 ==========

function your_plugin_on_user_registered(int $userId, string $email): void {
    // 处理用户注册事件
    $config = your_plugin_get_config();
    if (empty($config['enabled'])) return;
    // 业务逻辑...
}

function your_plugin_filter_content(string $content): string {
    // 修改文章内容(过滤器钩子必须返回值)
    return $content . '<p>附加内容</p>';
}

5.2 函数命名约定

所有全局函数必须以 {插件slug}_ 为前缀,避免与其他插件或内核冲突:

类型命名格式示例
激活回调{slug}_activateemail_notification_activate
停用回调{slug}_deactivateemail_notification_deactivate
卸载回调{slug}_uninstallemail_notification_uninstall
配置读取{slug}_get_configemail_notification_get_config
保存设置{slug}_save_settingsemail_notification_save_settings
钩子回调{slug}_on_{事件}email_notification_on_order_paid
工具类{slug}_{描述}email_notification_send_mail

六、后台设置页

当 plugin.json 中 has_settings 为 true 时,系统会加载 admin_settings.php 作为设置页面。

6.1 页面模板结构

<?php
$config = your_plugin_get_config();
?>
<style>
.your-plugin-settings { padding: 0; }
.your-plugin-settings .form-group { margin-bottom: 16px; }
.your-plugin-settings label { display: block; margin-bottom: 8px; }
.your-plugin-settings input,
.your-plugin-settings select { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; }
</style>

<div class="your-plugin-settings">
    <div class="form-group">
        <label>启用功能</label>
        <input type="checkbox" name="enabled" value="1"
            <?php echo !empty($config['enabled']) ? 'checked' : ''; ?>>
    </div>

    <div class="form-group">
        <label>选项名称</label>
        <input type="text" name="option_name"
            value="<?php echo htmlspecialchars($config['option_name'] ?? ''); ?>">
    </div>

    <div class="form-group">
        <label>选择项</label>
        <select name="select_option">
            <option value="a" <?php echo ($config['select_option'] ?? '') === 'a' ? 'selected' : ''; ?>>选项A</option>
            <option value="b" <?php echo ($config['select_option'] ?? '') === 'b' ? 'selected' : ''; ?>>选项B</option>
        </select>
    </div>
</div>

6.2 保存机制

系统自动处理表单提交,调用 {slug}_save_settings() 函数保存设置。表单字段名直接对应配置数组的键。

七、配置系统

7.1 配置文件格式

使用 PHP return array 格式,相比 JSON 支持注释和更丰富的数据类型:

<?php
/**
 * 邮件通知插件默认配置
 */
return [
    'enabled' => 0,
    'host' => 'smtp.example.com',
    'port' => 587,
    'secure' => 'tls',
    'notify' => [
        'user_register' => 1,
        'order_paid' => 1,
    ],
];

7.2 配置读取流程

读取配置
    ├── 优先读取 config/config.php(用户自定义配置)
    └── 不存在则读取 config/config.default.php(默认配置)

保存配置
    ├── 读取当前配置(合并默认值)
    ├── 校验并更新指定字段
    └── 写入 config/config.php(PHP return array 格式)

7.3 配置设计原则

  • 默认值完备:config.default.php 必须包含所有配置项的默认值
  • 白名单保存:save_settings 只更新预定义的 validKeys 列表中的字段
  • 类型明确:开关用 int(0/1),数字用 int/float,避免字符串类型混乱
  • 分组清晰:相关配置用子数组分组(如 notify 子数组存通知开关)
  • 向后兼容:新增配置项时,读取函数需处理键不存在的情况(?? 运算符)

八、生命周期回调详解

8.1 激活回调

管理员点击「激活」时执行,常用于:创建默认配置、初始化数据表、创建目录等。

function your_plugin_activate() {
    // 1. 复制默认配置
    $configFile = __DIR__ . '/config/config.php';
    if (!file_exists($configFile)) {
        copy(__DIR__ . '/config/config.default.php', $configFile);
    }

    // 2. 创建数据表(如有需要,也可在 SQL 文件中定义)
    // $db = Database::getInstance();
    // $db->execute("CREATE TABLE IF NOT EXISTS ...");

    // 3. 创建必要目录
    $uploadDir = __DIR__ . '/uploads';
    if (!is_dir($uploadDir)) {
        mkdir($uploadDir, 0755, true);
    }

    return ['success' => true, 'message' => '插件已激活'];
}

8.2 停用回调

管理员点击「停用」时执行,一般只清理运行时缓存,不删除数据。

function your_plugin_deactivate() {
    // 清理缓存
    // Cache::delete('your_plugin_cache_key');

    return ['success' => true, 'message' => '插件已停用'];
}

8.3 卸载回调

管理员点击「卸载」时执行,必须清理所有插件数据:配置文件、数据表、上传文件等。

function your_plugin_uninstall() {
    $deletedTables = [];
    $errors = [];

    // 1. 删除配置文件
    $configFile = __DIR__ . '/config/config.php';
    if (file_exists($configFile)) {
        unlink($configFile);
    }

    // 2. 删除数据表(也可在 plugin.json 的 drop_tables 中声明)
    // $db = Database::getInstance();
    // $db->execute("DROP TABLE IF EXISTS your_plugin_table");
    // $deletedTables[] = 'your_plugin_table';

    // 3. 删除上传目录
    // $uploadDir = __DIR__ . '/uploads';
    // if (is_dir($uploadDir)) {
    //     array_map('unlink', glob($uploadDir . '/*'));
    //     rmdir($uploadDir);
    // }

    return [
        'success' => empty($errors),
        'message' => empty($errors) ? '插件已卸载' : '卸载完成,存在错误',
        'deleted_tables' => $deletedTables,
        'errors' => $errors,
    ];
}

九、安全规范

9.1 文件访问保护

正确写法错误写法
if (!defined('ROOT_PATH')) {
    exit('Access denied');
}
// 没有安全检查,直接执行
// 可被直接 URL 访问

9.2 XSS 防护

正确写法错误写法
echo htmlspecialchars(
    $config['name'] ?? ''
);
echo $config['name'];

9.3 SQL 注入防护

正确写法错误写法
$db->fetchAll(
    "SELECT * FROM table WHERE id = ?",
    [$id]
);
$db->fetchAll(
    "SELECT * FROM table WHERE id = $id"
);

9.4 CSRF 防护

后台设置页面的表单提交由系统自动处理 CSRF 验证,插件无需额外处理。如自定义前端表单,需自行添加 CSRF Token。

9.5 文件操作安全

正确写法错误写法
$file = __DIR__ . '/config/'
    . basename($filename);
if (is_file($file)) { ... }
$file = $_GET['file'];
unlink($file);

十、前端资源注入

插件可通过模板钩子向前端页面注入 CSS 和 JS。

10.1 注入 CSS

// 在 hooks.json 中注册 index_head 钩子
// { "hook": "index_head", "type": "action", "callback": "your_plugin_enqueue_styles" }

function your_plugin_enqueue_styles() {
    $cssUrl = '/plugins/your_plugin/assets/css/style.css';
    echo '<link rel="stylesheet" href="' . $cssUrl . '?v=1.0">';
}

10.2 注入 JS

// 在 hooks.json 中注册 footer_after 钩子
// { "hook": "footer_after", "type": "action", "callback": "your_plugin_enqueue_scripts" }

function your_plugin_enqueue_scripts() {
    $jsUrl = '/plugins/your_plugin/assets/js/main.js';
    echo '<script src="' . $jsUrl . '?v=1.0"></script>';
}

10.3 常用注入钩子

钩子位置用途
index_head</head> 前注入 CSS、meta 标签
footer_after</footer> 后注入 JS 脚本
header_after</header> 后页面顶部横幅、公告
footer_before<footer> 前页脚上方内容

十一、常用钩子参考

11.1 用户相关

钩子名类型参数说明
user_registeredaction$userId, $email, $username用户注册成功后
email_verification_requestaction$userId, $email, $token请求邮箱验证时
password_reset_requestaction$userId, $email, $token请求重置密码时

11.2 订单相关

钩子名类型参数说明
order_paidaction$orderId, $userId, $amount, $email订单支付成功后
admin_notify_new_orderaction$orderId, $userId, $amount, $username, $email新订单管理员通知

11.3 评价相关

钩子名类型参数说明
review_submittedaction$reviewId, $userId, $appId, $email评价提交后
admin_notify_new_reviewaction$reviewId, $userId, $appId, $username, $email新评价管理员通知

十二、调试技巧

12.1 钩子调试

// 查看某个钩子已注册的所有回调
$hooks = HookManager::getActions('hook_name');

// 临时输出所有已注册钩子(调试用)
// HookManager::debugDump();

12.2 错误日志

// 使用系统 error_log 记录调试信息
error_log('[your_plugin] 调试信息: ' . $var);

// 更规范的写法
function your_plugin_log(string $message): void {
    error_log('[your_plugin] ' . $message);
}

12.3 常见问题排查

问题可能原因排查方法
钩子不生效hooks.json 格式错误或函数未定义检查 JSON 语法、确认回调函数存在
激活失败回调函数返回格式不对检查 activate 函数返回数组结构
设置保存无效字段不在 validKeys 中检查 save_settings 中的白名单
CSS/JS 不加载钩子未注册或路径错误检查 hooks.json 和资源 URL
数据库操作报错SQL 语法或参数错误使用参数化查询,检查字段名

十三、发布清单

发布插件前,请逐项检查:

  1. plugin.json 信息完整且正确(name, slug, version, author)
  2. hooks.json 中声明的所有回调函数在 plugin.php 中都有定义
  3. 所有 PHP 文件开头有安全检查(defined ROOT_PATH / CNLOG_ROOT)
  4. 函数名、类名都以插件 slug 为前缀,无命名冲突
  5. 输出用户内容时使用 htmlspecialchars() 转义
  6. 数据库操作使用参数化查询,无 SQL 注入风险
  7. 激活回调能正常创建默认配置
  8. 停用回调不删除数据
  9. 卸载回调清理所有插件数据(配置、数据表、文件)
  10. config.default.php 包含所有配置项的默认值
  11. save_settings 使用白名单方式保存字段
  12. 前端资源路径正确,附加版本号防缓存
  13. 目录权限正确(config 目录可写)
  14. PHP 最低版本声明与实际使用语法一致
  15. README.md 包含安装说明和使用方法