主站
论坛首页/ LiteLog /帖子

LiteLog 插件开发规范(最新完善版)

作者:zhangwei 管理员 · 8 天前 · 21 浏览 · 0 回复
zhangwei
楼主
管理员
1 楼

LiteLog 插件开发规范(v1.1)

版本:1.1(2026-10-10) 适用范围:LiteLog 内核 content/plugins/ 目录下所有插件 配套代码:include/lib/plugin_registry.php、include/lib/hook.php、include/lib/widget.php、include/lib/homepage_mode.php、include/lib/function.common.php、init.php、admin/index.php、include/lib/dispatcher.php 本规范中所有钩子/触发点已按 LiteLog v1.0.0 内核逐行核实 v1.1 新增:第十五章「辅助材料:插件 500 错误规避实战(点赞送分插件)」 给 AI 下指令时,请把本文件完整附上。

目录

  1. 总则:三条硬性安全规则
  2. 最高级别红线
  3. 插件目录结构
  4. 插件主文件与头注释
  5. 生命周期文件
  6. PluginRegistry 注册表
  7. 钩子系统全览
  8. 数据库与模型
  9. 视图与模板
  10. 资源文件
  11. 小工具与首页模式扩展
  12. 常见坑与红线清单
  13. 开发流程与验证
  14. 给 AI 的指令模板
  15. 辅助材料:插件 500 错误规避实战 ← v1.1 新增

一、总则:三条硬性安全规则

三条铁律,任何插件代码(主文件、钩子、控制器、视图)都必须遵守。给 AI 下指令时直接抄这三段。

规则 1 — 文件操作,无 basename 不落盘

任何涉及 unlink()、file_get_contents()、file_put_contents()、move_uploaded_file()、fopen() 的插件代码,一律遵循:

$safeName = basename($userInput);
if ($safeName === '' || $safeName !== $userInput) {
    // 拦截:非法文件名,直接返回
    return false;
}
$safePath = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $safeName;
if (is_file($safePath)) unlink($safePath);

铁律:凡是文件名,一律先过 basename(),不允许直接拼用户输入。彻底掐断路径穿越。

规则 2 — 数据库查询,无预处理不执行

任何 SQL 操作必须 prepare() + execute([...]) 占位符绑定,禁止字符串拼接。ID 一律 (int) 强转:

// ✅ 正确
$row = $DB->fetchOne(
    "SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=?",
    [(int)$gid]
);

// ✅ 批量 IN 必须 intval + 占位符
$ids = array_map('intval', $ids);
$in  = implode(',', array_fill(0, count($ids), '?'));
$rows = $DB->fetchAll("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid` IN ($in)", $ids);

// ❌ 严禁
$DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=$gid");

铁律:所有查询必须带占位符绑定或 (int) 强转。禁止任何用户输入直接进 SQL。

规则 3 — 输出字符串必须转义

任何输出到 HTML 的字符串,一律过 h()(即 htmlspecialchars($s, ENT_QUOTES, 'UTF-8')):

// ✅
echo '<h1>' . h($log['title']) . '</h1>';
echo '<a href="' . h($url) . '">' . h($name) . '</a>';

// ❌
echo '<h1>' . $log['title'] . '</h1>';

视图里也可以直接调用核心 h() 函数(include/lib/function.base.php 已定义,全局可用)。

铁律:凡输出到页面的字符串,绝不能绕过 h()。富文本内容需要展示 HTML 时,必须先经过白名单过滤或明确信任来源(管理员输入)。


二、最高级别红线

以下 8 条为一票否决级红线,任何插件违反任一条件直接判定不合格。

红线 1 — 严禁反序列化与动态执行

禁止使用 unserialize()、eval()、assert()、create_function()、preg_replace('/e')、call_user_func($_GET[...]) 等动态执行入口。数据序列化统一用 json_encode() / json_decode($s, true)。

红线 2 — 严禁滥用绝对路径

所有路径必须基于 LITELOG_ROOT、CONTENT_PATH、ADMIN_PATH、INCLUDE_PATH 常量,或 DIR 拼接。禁止 $_SERVER['DOCUMENT_ROOT'] 直接拼路径。插件目录内引用统一 DIR . '/xxx'。

红线 3 — 渲染钩子内禁止中断页面

所有渲染类钩子(index_head、admin_head、admin_footer、log_list_before、log_item_meta 等)内绝不能使用 die() / exit() / header()。必须 return; 返回。这些钩子在布局输出过程中执行,中断会毁整页或触发 "headers already sent"。

红线 4 — 上传文件必须白名单 + 随机文件名

强制扩展名白名单(['jpg','jpeg','png','gif','webp','bmp'] 等),保存时重命名为强随机文件名,禁止保留原始文件名:

$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg','png','gif'], true)) { return; }
$filename = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
// 禁止:md5(microtime().rand()) 弱随机;禁止保留原名 $file['name']

上传前必须校验真实性:图片用 getimagesize(),其它类型用 MIME 白名单。

红线 5 — 数量扣减必须原子更新

库存 / 积分 / 次数扣减必须原子 UPDATE 并检查 rowCount(),严禁先 SELECT 再 UPDATE(并发下超卖/超扣):

$stmt = $DB->query(
    "UPDATE `" . DB_PREFIX . "goods` SET `stock`=`stock`-1 WHERE `id`=? AND `stock`>0",
    [(int)$id]
);
if ($stmt->rowCount() === 0) {
    // 库存不足,拒绝
    return ['ok' => false, 'msg' => '库存不足'];
}

红线 6 — IN (...) 必须强转 + 占位符

// ✅
$ids = array_map('intval', $ids);
$in  = implode(',', array_fill(0, count($ids), '?'));
$DB->fetchAll("SELECT * FROM t WHERE id IN ($in)", $ids);

// ❌ 严禁
$DB->fetchAll("SELECT * FROM t WHERE id IN (" . implode(',', $ids) . ")");

红线 7 — PDO 属性不得覆盖

核心 Database 已统一设置 PDO::ATTR_ERRMODE = ERRMODE_EXCEPTION + PDO::ATTR_EMULATE_PREPARES = false。插件不得调用 setAttribute() 覆盖这两个属性,也不要改动 defaultFetchMode。

红线 8 — AJAX 写操作必须有登录校验

所有异步 POST / 数据修改接口,第一件事是校验登录:

if (empty($_SESSION['uid'])) {
    echo json_encode(['ok' => false, 'msg' => '未登录']);
    exit;
}

涉及管理员操作的,还要校验 $_SESSION['role'] === 'admin'。

说明:LiteLog 核心当前未提供 CSRF 防护机制(无 token 生成/校验 API)。插件若需要更高安全级别,可自行实现: 1. 在登录会话里放 $_SESSION['_csrf'] = bin2hex(random_bytes(16)); 2. 表单渲染时输出隐藏域 <input type="hidden" name="_csrf" value="<?= h($_SESSION['_csrf']) ?>">; 3. POST 处理时校验 hash_equals($_SESSION['_csrf'] ?? '', $_POST['_csrf'] ?? '')。 但 LiteLog 官方推荐:非必要不加,避免与核心未来实现冲突。

三、插件目录结构

一目录一插件:目录名小写下划线(如 friend_links、seo_tools),与插件主文件名一致。

content/plugins/{插件名}/
├── {插件名}.php              ← 【必须】主文件 + 插件头注释
├── activate.php              ← 【可选】启用时执行(建表等)
├── deactivate.php            ← 【可选】停用时执行
├── uninstall.php             ← 【可选】卸载时执行(删表/删数据)
├── admin/
│   ├── register.php          ← 【可选】自动加载:注册后台页面/菜单/资源
│   ├── pages/                ← 后台视图(PHP 文件)
│   │   ├── list.php
│   │   └── edit.php
│   └── assets/               ← 后台专属 CSS/JS
│       ├── admin.css
│       └── admin.js
├── frontend/
│   ├── routes.php            ← 【可选】自动加载:注册前台路由
│   └── views/                ← 前台视图(PHP 文件)
│       └── show.php
├── views/                    ← 【可选】通用视图(前后台共用)
├── assets/                   ← 【可选】插件静态资源(Web 直接可访问)
│   ├── style.css
│   └── script.js
└── data/                     ← 【可选】插件独立数据目录(建议加 .htaccess 禁 Web)

核心自动加载的 4 个约定文件:

路径何时加载用途
{插件名}.php每次请求(插件启用时)主文件、类定义、插件头注释
admin/register.php每次请求注册后台页面/菜单/资源
frontend/routes.php每次请求注册前台路由
activate.php / deactivate.php / uninstall.php生命周期触发时建表/清理

禁止:

  • 在插件目录外(核心 admin/、include/ 目录)写任何文件。
  • 修改核心文件(admin/index.php、admin/views/layout.php、include/lib/* 等)——所有扩展点都必须走 PluginRegistry + Hook。
  • 遗留 .bak / .tmp / .old / .revert 等临时文件。

四、插件主文件与头注释

主文件 content/plugins/{插件名}/{插件名}.php 是插件的入口。开头必须是标准注释头(供 parseHeader() 解析):

<?php
/**
 * Plugin Name: 友情链接
 * Description: 管理友情链接,支持排序与前台展示
 * Version: 1.0.0
 * Author: 你的名字
 * Author URI: https://example.com
 */
if (!defined('LITELOG_ROOT')) exit('error!');

// 插件业务逻辑,推荐用 class 包裹
class FriendLinks {
    const TABLE = 'friend_links';

    public static function all() {
        global $DB;
        return $DB->fetchAll(
            "SELECT * FROM `" . DB_PREFIX . self::TABLE . "`
             WHERE `visible`=1 ORDER BY `sort_order` ASC, `id` ASC"
        );
    }
    public static function getOne($id) {
        global $DB;
        return $DB->fetchOne(
            "SELECT * FROM `" . DB_PREFIX . self::TABLE . "` WHERE `id`=?",
            [(int)$id]
        );
    }
    public static function save($id, $data) {
        global $DB;
        return $id
            ? $DB->update(self::TABLE, $data, "id=?", [$id])
            : $DB->insert(self::TABLE, $data);
    }
    public static function delete($id) {
        global $DB;
        $DB->delete(self::TABLE, "id=?", [(int)$id]);
    }
}

头注释支持的字段(admin/helpers.php::parseHeader() 逐行解析前 30 行):

字段说明备选写法
Plugin Name显示名(中文/英文均可)Name
Description功能描述—
Version版本号(如 1.0.0)—
Author作者—
Author URI作者主页Author URL

约定:

  • 主文件必须能被直接 require_once 加载,不得有副作用(如直接 echo、直接 header())。所有初始化写在钩子或生命周期文件里。
  • 类定义推荐放在主文件,方便核心和其它文件引用。
  • 复杂逻辑建议拆分为多个 PHP 文件(同目录),由主文件 require_once DIR . '/xxx.php' 加载。核心只强制加载主文件 + admin/register.php + frontend/routes.php 这三个约定文件。
  • ⚠️ 主文件里所有 define() / function xxx() 定义必须带 if (!defined(...)) / if (!function_exists(...)) 守卫(见第十五章 15.2 罪 2)。

五、生命周期文件

三个可选文件,分别在插件启用 / 停用 / 卸载时被核心执行。

activate.php — 启用时执行

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
global $DB;

$DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "friend_links` (
    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `name` VARCHAR(100) NOT NULL,
    `url` VARCHAR(255) NOT NULL,
    `description` VARCHAR(255) DEFAULT '',
    `logo` VARCHAR(255) DEFAULT '',
    `sort_order` INT DEFAULT 0,
    `visible` TINYINT(1) DEFAULT 1,
    `created_at` INT UNSIGNED DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

约定:

  • 建表用 CREATE TABLE IF NOT EXISTS(幂等),不要用 SHOW TABLES LIKE 探测。
  • 建表失败(抛异常)→ 核心会写 content/backup/plugin-error.log,但不会回滚启用状态。所以插件自身必须保证激活逻辑足够健壮,或在校验失败时抛异常提醒用户手动停用。
  • 严禁在 activate.php 里输出任何内容(echo / print / var_dump)。
  • ⚠️ 整个 activate.php 必须用 try { ... } catch (Throwable $e) { ... } 包起来(见第十五章 15.2 罪 5)。
  • ⚠️ DDL 语句不要使用 COMMENT / COLLATE / 特殊 ENGINE 等可选语法(见第十五章 15.2 罪 5)。

deactivate.php — 停用时执行

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
// 停用是"临时关闭",不要删表、不要清数据
// 只做:清临时缓存、取消定时任务、写日志

约定:deactivate.php 是临时禁用,不要删表(删表是 uninstall.php 的职责)。否则重新启用时数据全丢。

uninstall.php — 卸载时执行

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
global $DB;

// ① 删插件自己建的表
$DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "friend_links`");

// ② 清核心 option 表里本插件的设置
$DB->delete('option', "option_name LIKE 'friend_links_%'");

约定:

  • 清理顺序:先删表 → 再清 option / 用户字段残留。
  • plugins_cache.json 由系统自动重建,不需要也不应该在插件里手动删。
  • 卸载前核心会校验插件已停用,所以卸载脚本不需要处理"正在启用中"的情况。

生命周期在 admin/index.php 里的调用顺序

启用:写 active_plugins 列表 → 执行 activate.php → 触发 plugin_activate 钩子
停用:从 active_plugins 移除 → 执行 deactivate.php → 触发 plugin_deactivate 钩子
卸载:校验已停用 → 执行 uninstall.php → 触发 plugin_uninstall 钩子(插件文件不会自动删除,需用户手动删目录)

六、PluginRegistry 注册表

这是 LiteLog 插件系统的核心。所有后台页面、菜单、前台路由、页面资源都必须经此注册。核心通过读取注册表动态渲染,从此插件永远不需要改核心代码。

include/lib/plugin_registry.php 里 PluginRegistry 提供了 6 个方法:

6.1 addAdminPage($action, $config) — 注册后台页面

PluginRegistry::addAdminPage('friend_links', [
    'title'         => '友情链接管理',                        // 页面标题
    'callback'      => function () { ... },                   // 输出内容(可 echo)
    'view'          => __DIR__ . '/pages/list.php',           // 或指定视图文件
    'headerActions' => '<a class="btn" href="...">新增</a>',  // 右上角按钮(可选)
    'assets'        => [
        'css' => [BLOG_URL . 'content/plugins/friend-links/admin/assets/admin.css'],
        'js'  => [BLOG_URL . 'content/plugins/friend-links/admin/assets/admin.js'],
    ],
]);

注册后,访问 admin/?action=friend_links 即渲染此页面。callback 或 view 二选一(callback 优先)。页面会被核心 admin/views/layout.php 包上后台骨架(侧边栏、头部、内容区)。

约定:

  • action 命名建议统一前缀 {插件名}_xxx(如 friend_links、friend_links_edit),避免与核心 action 冲突。
  • callback 里可以 global $DB 查数据,然后 include DIR . '/pages/list.php'。
  • 视图内不要再 include layout.php——核心已包好骨架,视图只需输出主体内容。
  • ⚠️ assets 字段因内核版本差异兼容性不稳定,推荐做法是视图内自行 <link> / <script> 输出(见第十五章 15.2 罪 4)。

6.2 addAdminMenu($menu) — 注册后台菜单

PluginRegistry::addAdminMenu([
    'id'       => 'friend_links',       // 用于高亮的唯一 key
    'action'   => 'friend_links',       // 对应 addAdminPage 的 action
    'title'    => '友情链接',            // 侧边栏显示名
    'icon'     => 'external',           // adminIcon() 支持的图标名
    'priority' => 30,                   // 越小越靠前(默认 50)
]);

注册后侧边栏「扩展」区块自动多出一项,点击跳 ?action=friend_links,且当页面处于该 action 时自动高亮。

icon 支持的名字(来自 admin/helpers.php::adminIcon()):

dashboard, file, comment, folder, settings, external, logout, plus,
edit, trash, plugin, palette, check, x, refresh

6.3 addFrontendRoute($regex, $callback) — 注册前台路由

PluginRegistry::addFrontendRoute('links/?', function ($matches) {
    global $DB;
    $links = $DB->fetchAll(
        "SELECT * FROM `" . DB_PREFIX . "friend_links`
         WHERE visible=1 ORDER BY sort_order ASC, id ASC"
    );
    include __DIR__ . '/views/show.php';
});
  • $regex 是不带前导斜杠的正则(^、$ 由系统自动加)。可以带捕获组:'links/(\d+)'、'u/([a-z0-9_]+)'。
  • 捕获组通过 $matches 传入,同时也会灌进 $_GET(命名捕获组用 $_GET[$name])。
  • 匹配条件:内置伪静态(post/.html、sort/、tag/、author/、record/、page/)未命中,且 $_GET['action'] 未设置。
  • 路由优先级:后注册的先匹配,插件内自定义顺序即可。

6.4 addAdminAsset($action, $type, $url) — 按页面加载资源

PluginRegistry::addAdminAsset('friend_links', 'css', BLOG_URL . 'content/plugins/friend-links/assets/admin.css');
PluginRegistry::addAdminAsset('friend_links', 'js',  BLOG_URL . 'content/plugins/friend-links/assets/admin.js');

等价于 addAdminPage 里的 assets 字段。按页面精准加载:只在访问对应 action 时输出,不会污染其它页面。推荐优先用 addAdminPage 的 assets。

6.5 hasAdminPage($action) / getAdminPage($action) — 查询

核心内部使用,插件一般不需要调用。

6.6 完整示例:admin/register.php

⚠️ v1.1 起强制要求:admin/register.php 内所有代码必须包在 try { ... } catch (Throwable $e) { ... } 里,并对 class_exists('PluginRegistry') 做前置守卫(见第十五章 15.2 罪 1)。
<?php
if (!defined('LITELOG_ROOT')) exit('error!');

if (!class_exists('PluginRegistry')) return;

try {
    PluginRegistry::addAdminMenu([
        'id'       => 'friend_links',
        'action'   => 'friend_links',
        'title'    => '友情链接',
        'icon'     => 'external',
        'priority' => 30,
    ]);

    PluginRegistry::addAdminPage('friend_links', [
        'title'    => '友情链接管理',
        'callback' => function () {
            include __DIR__ . '/pages/list.php';
        },
    ]);
} catch (Throwable $e) {
    @file_put_contents(
        CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] friend_links admin register: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX
    );
}

admin/pages/list.php 示例(不要再 include layout.php,核心已包好):

<?php if (!defined('LITELOG_ROOT')) exit('error!'); ?>
<div class="card">
    <div class="card-title">全部友情链接</div>
    <div class="table-wrap">
        <table class="table">
            <thead>
                <tr>
                    <th>名称</th><th>URL</th><th>排序</th><th>状态</th><th>操作</th>
                </tr>
            </thead>
            <tbody>
            <?php foreach ($links as $l): ?>
                <tr>
                    <td><strong><?= h($l['name']) ?></strong></td>
                    <td><?= h($l['url']) ?></td>
                    <td><?= (int)$l['sort_order'] ?></td>
                    <td>
                        <?php if ($l['visible']): ?>
                            <span class="tag tag-published">显示</span>
                        <?php else: ?>
                            <span class="tag tag-hidden">隐藏</span>
                        <?php endif; ?>
                    </td>
                    <td class="row-actions">
                        <a href="?action=friend_links_edit&id=<?= (int)$l['id'] ?>">编辑</a>
                        <a class="danger" href="?action=friend_links_delete&id=<?= (int)$l['id'] ?>"
                           onclick="return confirm('确认删除?')">删除</a>
                    </td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
    </div>
</div>

七、钩子系统全览

include/lib/hook.php 提供 5 个方法:

方法说明
Hook::addAction($hook, $func, $priority = 10)注册回调
Hook::doAction($hook, ...$args)触发钩子
Hook::removeAction($hook, $func = null)移除回调(null 则清空该钩子)
Hook::hasAction($hook)是否已挂载可调用回调
Hook::captureAction($hook, ...$args)捕获输出并返回字符串

优先级:数字越小越先执行,默认 10。同优先级按注册顺序。

重要限制:LiteLog 的 Hook 只有 action(副作用)语义,没有 filter(改值)语义。需要修改数据时,通过引用传参实现:

// 核心触发时
Hook::doAction('log_save_before', $data, $gid);

// 插件监听时($data 是引用,可以直接改)
Hook::addAction('log_save_before', function (&$data, $gid) {
    $data['title'] = '[置顶] ' . $data['title'];
});

7.1 后台钩子

钩子名触发点参数说明
admin_requestadmin/index.php 登录校验后$action每次后台请求都会触发
admin_headadmin/views/layout.php <head> 内无注入后台 CSS / meta
admin_footeradmin/views/layout.php </body> 前无注入后台 JS
admin_menulayout.php 侧边栏「扩展」区$activeKey在侧边栏插入菜单(不推荐,用 PluginRegistry::addAdminMenu 更好)
admin_dashboard_widgetsviews/dashboard.php 末尾无概览页扩展区
admin_log_edit_sidebarviews/log_edit.php 右侧栏底部$log文章编辑页附加面板
user_login登录成功后$user用户登录事件
user_logout登出前$uid用户登出事件
admin_log_save_before文章保存前&$data, $gid可改 $data
admin_log_save_after文章保存后$gid, $data—
plugin_activate插件启用后$name—
plugin_deactivate插件停用后$name—
plugin_uninstall插件卸载后$name—
theme_activate主题切换后$new, $old—
option_save系统设置保存单键后$key, $value—

7.2 前台通用钩子

钩子名触发点参数说明
dispatch_startDispatcher::dispatch() 开头无路由分发开始
dispatch_endDispatcher::dispatch() 结尾无路由分发结束
dispatch_matched路由匹配完成后&$matched可改 controller / method
dispatch_plugin_route__plugin_path 路由前$pluginName, $rest老式插件路由
public_action_xxx?action=xxx 分发前无例:?action=comment_post 触发 public_action_comment_post

7.3 内容展示钩子

钩子名触发点参数说明
index_headLogController::display()无列表页 head 区
log_list_query列表查询前&$cond, $page, $perPage可改查询条件
log_list_result列表查询后&$logs, $page, $total可改列表数据
log_list_before列表输出前(HomepageMode_Blog::render)无—
log_list_head列表 title 之后、循环之前无—
log_list_after列表输出后无—
log_item_before每篇文章输出前(循环内)$log每篇触发一次
log_item_meta文章 meta 行$log每篇触发一次
log_item_body_after文章正文后$log每篇触发一次
log_item_after文章整块输出后$log每篇触发一次
log_display_before文章详情渲染前$log—
log_display_after文章详情渲染后$log—
log_content文章正文输出$log正文区
record_display归档页渲染后$year, $month, $logs—
sort_display分类页渲染后$sort, $logs—
tag_display标签页渲染后$tag, $logs—

7.4 评论钩子

钩子名触发点参数说明
comment_save_beforeCommentController::handlePost()&$data可改 $data(如加 uid)
comment_save_after评论插入后$cid, $data—
comment_post评论提交完成后$data—
public_action_comment_post?action=comment_post无评论提交入口

7.5 扩展点钩子(供插件注册)

钩子名触发点说明
register_widgetsWidgetRegistry::init()注册自定义小工具
register_homepage_modesHomepageModeRegistry::init()注册首页模式
permalink_build_log_urlUrl::log()自定义文章 URL(写入 $GLOBALS['__permalink_url_log'])
permalink_build_sort_urlUrl::sort()自定义分类 URL(写入 $GLOBALS['__permalink_url_sort'])

7.6 钩子注册方式

方式 A:主文件里直接注册(推荐)

// content/plugins/friend_links/friend_links.php
Hook::addAction('log_display_after', function ($log) {
    // ...
});
⚠️ v1.1 起强制要求:所有 Hook::addAction(...) 调用必须包在 try { ... } catch (Throwable $e) { ... } 里(见第十五章 15.2 罪 7)。

方式 B:拆成独立文件(复杂钩子推荐)

// content/plugins/friend_links/friend_links.php
require_once __DIR__ . '/hooks.php';

// content/plugins/friend_links/hooks.php
Hook::addAction('admin_head', function () {
    echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/friend_links/assets/admin.css">';
});

钩子命名规范:核心钩子名保持原样;插件自己触发供其它插件监听的钩子,必须加插件名前缀,如 friend_links_before_save、friend_links_after_save。


八、数据库与模型

8.1 数据库连接

核心 PDO 单例通过全局 $DB 访问:

global $DB;

$DB->query($sql, $params = []);              // 返回 PDOStatement
$DB->fetchAll($sql, $params = []);           // 返回二维数组
$DB->fetchOne($sql, $params = []);           // 返回一维数组或 null
$DB->fetchColumn($sql, $params = []);        // 返回第一列标量
$DB->insert($table, $data);                  // 返回 lastInsertId
$DB->update($table, $data, $where, $whereParams = []);
$DB->delete($table, $where, $params = []);
$DB->prefix();                               // 返回 DB_PREFIX
$DB->pdo();                                  // 返回原生 PDO

表名前缀:所有核心表必须带 DB_PREFIX 前缀:

// ✅
"SELECT * FROM `" . DB_PREFIX . "blog` WHERE gid=?"

// ❌
"SELECT * FROM blog WHERE gid=?"

插件自定义表也推荐加 DB_PREFIX 前缀,避免和核心表冲突。例如插件 friend_links 建表名可以是 {DB_PREFIX}friend_links。

8.2 表结构管理

  • 建表语句放 activate.php,用 CREATE TABLE IF NOT EXISTS。
  • 后续需要加字段:不能在 activate.php 里重复 ALTER(会造成每次启用都 ALTER)。推荐做法:
  1. 检测字段是否存在(SHOW COLUMNS FROM ... LIKE ...)后再 ALTER;
  2. 用版本号记录(Option::set('friend_links_db_version', '2')),只在版本不匹配时执行迁移。

8.3 使用核心模型

LiteLog 已有模型:

模型主要方法
LogModelquery()、getOne()、getByAlias()、latest()、prev()、next()、add()、update()、delete()、addView()
SortModelall()、getOne()、getByAlias()、add()、update()、delete()、countInSort()
TagModelall()、getByName()、getByGid()、getGidsByTag()、attach()、detach()
CommentModelgetOne()、byLog()、byLogPaginated()、buildTree()、latest()、add()、delete()、countByLog()
UserModelgetOne()、getByUsername()、login()、update()

优先使用模型方法,不要直查核心表(避免绕过模型层的缓存/钩子/字段约束):

// ✅
$logs = LogModel::latest(10);
$user = UserModel::getOne($uid);

// ❌
$logs = $DB->fetchAll("SELECT * FROM " . DB_PREFIX . "blog ORDER BY date DESC LIMIT 10");

8.4 Option 配置存储

插件自己的设置不要塞进核心 option 表(会和核心设置混淆),推荐:

  • 少量配置:用 Option::get('{插件名}{键}') / Option::set(...),键名前缀强制 {插件名}。
Option::set('friend_links_title', '友情链接');
$title = Option::get('friend_links_title', '友情链接');
  • 复杂结构:JSON 编码后存 option,或存到插件的独立数据目录(content/plugins/{插件名}/data/config.json)。
⚠️ v1.1 起强制要求:Option::get() 的第二个参数必须是 '',绝不能传 null(见第十五章 15.2 罪 3)。

8.5 事务

Database 的 $DB 支持 beginTransaction() / commit() / rollBack()(透传 PDO):

$DB->pdo()->beginTransaction();
try {
    // 一系列写操作
    $DB->pdo()->commit();
} catch (Throwable $e) {
    if ($DB->pdo()->inTransaction()) {
        $DB->pdo()->rollBack();
    }
    // 记日志
    @file_put_contents(CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . '] ' . $e->getMessage() . "\n\n",
        FILE_APPEND | LOCK_EX);
    return ['ok' => false, 'msg' => '操作失败'];
}

坑:

  • 只能用 beginTransaction(),不能 $DB->begin()(不存在此方法)。
  • catch 内必须先 inTransaction() 判断,不能无条件 rollBack()(事务未开时会二次抛异常)。
  • 异常不能逃逸出 controller 层,AJAX 接口必须返回 JSON,否则前端 JSON.parse 失败。

九、视图与模板

9.1 视图位置

  • 后台视图:content/plugins/{插件名}/admin/pages/*.php,由 admin/register.php 里注册的 callback 或 view 引入。
  • 前台视图:content/plugins/{插件名}/views/.php 或 frontend/views/.php,由前台路由的 callback 引入。
  • 视图内不要 include layout:核心 admin/views/layout.php 已经包好后台骨架;前台由插件自己决定是否套模板。
  • ⚠️ v1.1 起强制要求:后台视图顶部必须对插件主文件里定义的函数做 function_exists 守卫(见第十五章 15.2 罪 6)。

9.2 通用约定

所有输出必须转义:

<h1><?= h($log['title']) ?></h1>
<a href="<?= h($url) ?>"><?= h($name) ?></a>

后台视图可用的核心 CSS 类:

类名用途
.card卡片容器
.card-title卡片标题
.btn / .btn-outline / .btn-danger / .btn-sm按钮
.table-wrap / .table表格
.form表单容器
.field表单字段
.tag / .tag-published / .tag-hidden标签
.empty空状态
.error错误提示
.pagination分页

按钮链接示例:

<a class="btn" href="?action=friend_links_edit">新增链接</a>
<a class="btn btn-outline" href="?action=friend_links">返回列表</a>
<a class="btn btn-danger btn-sm" href="?action=friend_links_delete&id=<?= (int)$l['id'] ?>"
   onclick="return confirm('确认删除?')">删除</a>

后台页头右侧按钮(通过 addAdminPage 的 headerActions 参数):

'headerActions' => '<a class="btn" href="?action=friend_links_edit">'
                 . adminIcon('plus') . ' 新增链接</a>',

adminIcon($name) 是 admin/helpers.php 提供的 SVG 图标生成函数,支持的名字见 6.2。

9.3 前台视图:调用核心模板

如果想复用核心主题布局,前台视图可以调用:

View::display('log_list', [
    'logs'     => $logs,
    'pageHtml' => $pageHtml,
    'pageType' => 'index',
]);

View::display($name, $data) 会自动查找 content/templates/{当前主题}/{name}.php,找不到时回退 content/templates/default/{name}.php。

$data 会通过 extract($data, EXTR_SKIP) 注入到视图作用域,视图里直接用 $logs、$pageHtml 即可。

9.4 常用辅助函数

来自 include/lib/function.base.php 和 function.common.php:

函数说明
h($s)HTML 转义
redirect($url)302 跳转 + exit
isLoggedIn()是否登录
checkLogin()强制登录(未登录跳转后台登录页)
dateFmt($ts)Y-m-d 格式化
datetimeFmt($ts)Y-m-d H:i 格式化
stripTags($s, $len = 0)去 HTML 标签 + 截断
getExcerpt($html, $len = 200)提取摘要
makeAlias($str)生成 URL 别名
pagination($page, $total, $perPage)分页 HTML
getPageNumber()获取当前页码
pageUrl($page)生成保留当前 GET 参数的分页链接

9.5 URL 生成

前台 URL 必须用 Url:: 静态方法或 BLOG_URL 常量拼接:

Url::log($gid, $alias);            // 文章链接
Url::sort($sid, $alias);           // 分类链接
Url::tag($name);                   // 标签链接
Url::author($uid);                 // 作者链接
Url::record('2026-10');            // 归档链接

后台 URL:BLOG_URL . 'admin/?action=' . $action。

资源 URL:BLOG_URL . 'content/plugins/{插件名}/assets/xxx.css'。

禁止硬编码 '/post/1.html'、'/admin/' 等,必须拼 BLOG_URL(兼容子目录部署)。


十、资源文件

10.1 目录

  • 插件静态资源:content/plugins/{插件名}/assets/ —— CSS、JS、图片。
  • 后台专属资源:content/plugins/{插件名}/admin/assets/。

10.2 加载方式

方式 A:通过 PluginRegistry::addAdminPage 的 assets 字段(后台推荐)

PluginRegistry::addAdminPage('friend_links', [
    'title'    => '友情链接',
    'callback' => function () { ... },
    'assets'   => [
        'css' => [BLOG_URL . 'content/plugins/friend_links/admin/assets/admin.css'],
        'js'  => [BLOG_URL . 'content/plugins/friend_links/admin/assets/admin.js'],
    ],
]);

只在访问该 action 时输出,不污染其它后台页。

⚠️ v1.1 起建议:assets 字段在不同内核版本兼容性不稳定,优先使用「方式 C:视图内直接输出」(见第十五章 15.2 罪 4)。

方式 B:admin_head 钩子(需要全局注入时用)

Hook::addAction('admin_head', function () {
    echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/friend_links/assets/admin.css">';
});

方式 C:后台视图内直接 <link> / <script>(v1.1 推荐)

<?php
$ver = @filemtime(CONTENT_PATH . 'plugins/friend_links/assets/admin.css') ?: 1;
?>
<link rel="stylesheet" href="<?= BLOG_URL ?>content/plugins/friend_links/assets/admin.css?v=<?= (int)$ver ?>">

10.3 版本号防缓存

静态资源建议带 filemtime 版本号:

$ver = @filemtime(__DIR__ . '/../assets/style.css') ?: 1;
echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/friend_links/assets/style.css?v=' . $ver . '">';

10.4 零内联原则

  • CSS 一律收敛到 assets/*.css,不要输出大段内联 <style>。
  • JS 一律收敛到 assets/*.js,不要输出大段内联 <script>。
  • onclick="..." 等内联事件属性:允许使用(LiteLog 核心 admin/views/.php 也有 onclick="return confirm(...)" 的用法),但大量逻辑应迁到 assets/.js 里用 addEventListener 绑定。

10.5 数据目录保护

插件如果建 data/ 目录存敏感数据(配置文件、SQLite 等),必须放 .htaccess 防止 Web 直接下载:

# content/plugins/{插件名}/data/.htaccess
<IfModule mod_authz_core.c>
    Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
    Order deny,allow
    Deny from all
</IfModule>

Nginx 用户则需要在站点配置里加 location ~ ^/content/plugins/[^/]+/data/ { deny all; }。


十一、小工具与首页模式扩展

LiteLog 提供两套注册表式扩展点,都用 Hook 挂载。

11.1 自定义小工具

继承 Widget 基类,然后注册:

class Widget_MyBlock extends Widget {
    public $id          = 'my_block';
    public $name        = '我的模块';
    public $description = '展示自定义内容';

    public function defaults() {
        return ['title' => '默认标题', 'count' => 5];
    }

    public function form($settings) {
        $s = array_merge($this->defaults(), $settings ?: []);
        $this->fieldText('title', $s['title'], '标题');
        $this->fieldNumber('count', $s['count'], '显示数量', 1, 20);
    }

    public function render($settings, $instanceId) {
        $s = array_merge($this->defaults(), $settings ?: []);
        ob_start();
        echo '<ul>';
        // ...
        echo '</ul>';
        $this->output($instanceId, $s['title'], ob_get_clean());
    }
}

Hook::addAction('register_widgets', function () {
    WidgetRegistry::register(new Widget_MyBlock());
});

内置基类方法:

方法说明
fieldText($name, $value, $label, $ph)输出文本框
fieldNumber($name, $value, $label, $min, $max)输出数字框
fieldSelect($name, $value, $options, $label)输出下拉框
output($instanceId, $title, $content)输出标准小工具外壳

表单字段必须带 data-setting="键名" 属性,后台 widgets.js 靠它收集值。

11.2 自定义首页模式

继承 HomepageMode 基类,然后注册:

class HomepageMode_MyMode extends HomepageMode {
    public $id          = 'my_mode';
    public $name        = '我的模式';
    public $description = '显示自定义首页内容';
    public $theme       = 'purple';       // blue/green/purple/orange/pink/cyan
    public $icon        = '<svg>...</svg>';
    public $priority    = 50;

    public function isAvailable() {
        return true;   // 返回 false 会让后台设置页禁用该模式
    }

    public function render($ctx = []) {
        echo '<div class="my-mode">';
        echo '<h1>' . h(Option::get('site_title')) . '</h1>';
        echo '</div>';
    }
}

Hook::addAction('register_homepage_modes', function () {
    HomepageModeRegistry::register(new HomepageMode_MyMode());
});

启用后,管理员可在「系统设置 → 首页主栏内容」里选择该模式。


十二、常见坑与红线清单

12.1 安全

  1. 路径穿越 —— 文件名一律过 basename()(规则 1)。
  2. SQL 注入 —— 必须占位符绑定或 (int) 强转(规则 2)。
  3. XSS —— 输出必须过 h()(规则 3)。
  4. IDOR —— 按 ID 操作(编辑/删除)必须校验资源存在;ID 一律 (int) 强转。
  5. 上传 —— 扩展名白名单 + 真实图片校验 + 随机文件名(红线 4)。
  6. 开放重定向 —— redirect($url) 只允许本站 URL,禁止用未过滤的用户输入。

12.2 部署兼容

  1. 不要硬编码路径 —— URL 必须拼 BLOG_URL,文件路径必须拼 LITELOG_ROOT / CONTENT_PATH / DIR。
  2. 子目录部署 —— 假设站点可能在 https://example.com/blog/ 部署,所有 URL 拼接都要能兼容。
  3. 不区分大小写文件系统 —— 插件目录名保持小写下划线,避免在 Linux 上跑挂。

12.3 结构规范

  1. 一目录一插件 —— 目录名小写下划线,与主文件名一致。
  2. 不留临时文件 —— .bak / .tmp / .old / .revert 一律清理。
  3. 不改核心文件 —— 所有扩展点走 PluginRegistry + Hook。
  4. 后台视图不 include layout —— 核心已包好骨架,只需输出主体。
  5. 插件自己的 option 加插件名前缀 —— 避免和核心/其它插件冲突。
  6. 生命周期文件不输出内容 —— activate.php / deactivate.php / uninstall.php 里禁止 echo。
  7. 所有文件 UTF-8 无 BOM,LF 行尾,用 php -l 校验语法。

12.4 性能

  1. 避免 N+1 查询 —— 批量取 ID 后一次性 IN (...) 补字段。
  2. 静态缓存 —— 同一请求内相同查询用 static 变量缓存。
  3. 钩子轻量化 —— 频繁触发的钩子(log_item_meta 每篇文章一次、comment_save_before 每条评论一次)里禁止重查询。
  4. 避免在 log_list_query / admin_request 等高频钩子里做同步 IO。

12.5 兼容性

  1. $DB 是全局 —— 在函数里用必须先 global $DB;。
  2. $data 引用参数 —— 修改 log_save_before / comment_save_before 的参数必须 function (&$data, ...)。
  3. redirect() 后必须 exit —— 核心 redirect() 已自带 exit,但自定义跳转要自己加。
  4. $_SESSION['uid'] 是登录标识 —— 判断登录用 !empty($_SESSION['uid']),不要用其它字段。

12.6 卸载自检

uninstall.php 执行后必须自查:

  1. 插件自建的表已删(DROP TABLE IF EXISTS)。
  2. 核心 option 表里本插件的键已清(DELETE FROM option WHERE option_name LIKE 'friend_links_%')。
  3. 用户表中本插件写入的字段已清(如果修改过 user 表结构)。
  4. 插件目录里的 data/ 目录如果含敏感文件,需要在 uninstall.php 里清理(但不删插件目录本身,那是用户的责任)。

十三、开发流程与验证

13.1 标准流程

  1. 建目录骨架:

B40

  1. 写主文件(头注释 + 类定义)。
  2. 写 activate.php(建表)。
  3. 写 admin/register.php(注册后台菜单 + 页面)。
  4. 写后台视图(admin/pages/*.php)。
  5. (可选)写 frontend/routes.php + 前台视图。
  6. (可选)写钩子回调(Hook::addAction(...))。
  7. 启用插件:后台 → 插件 → 启用。观察是否建表成功。
  8. 验证后台:侧边栏是否出现菜单,点击是否正常渲染。
  9. (如有前台)验证前台 URL。
  10. 停用 / 启用循环测试:确认无异常。
  11. 卸载测试:确认表和数据清理干净。

13.2 验证清单

项方法
语法正确php -l content/plugins/my_plugin/*.php
头注释完整后台插件列表页显示 Plugin Name 而非目录名
建表成功数据库里能看到 {前缀}my_plugin_xxx 表
后台菜单出现侧边栏「扩展」区多了菜单项
后台页面渲染点菜单能看到注册的页面内容
无副作用访问首页 / 文章页 / 评论页,页面无异常输出
卸载干净卸载后 SHOW TABLES LIKE '{前缀}my_plugin%' 为空
无残留 optionSELECT * FROM {前缀}option WHERE option_name LIKE 'my_plugin_%' 为空

13.3 常见错误定位

症状可能原因
后台菜单不出现admin/register.php 里没有 addAdminMenu;或插件未启用
点击菜单白屏callback 里抛异常但没输出;检查 content/backup/plugin-error.log
页面出现 404action 未注册,或 addAdminPage 拼写错误
数据表未创建activate.php 语法错误 / SQL 错误;看 plugin-error.log
前台 URL 404frontend/routes.php 正则没匹配;或在 ?action=xxx 时不会走插件路由
资源 404URL 没拼 BLOG_URL;或文件名大小写不符
CSS 不生效加载顺序问题;或路径不对;加 ?v=filemtime 强制刷新
后台整体 500见第十五章:admin/register.php 无 try/catch 是最大嫌疑犯

十四、给 AI 的指令模板

给 AI 下 LiteLog 插件开发/修改指令时,直接把本规范附上,并明确以下几点。

14.1 三条硬性规则(必须原样附给 AI)

规则 1 — 文件操作,无 basename 不落盘:任何涉及 unlink()、file_get_contents()、file_put_contents()、move_uploaded_file() 的代码,必须先用 basename() 过滤用户输入的文件名,再拼接安全路径。

规则 2 — 数据库查询,无预处理不执行:任何 SQL 必须 prepare() + execute([...]) 占位符绑定;ID 一律 (int) 强转;IN (...) 必须 intval + 占位符;禁止任何字符串拼接。

规则 3 — 输出必须转义:所有输出到 HTML 的字符串必须过 h()(htmlspecialchars($s, ENT_QUOTES, 'UTF-8'));富文本需白名单过滤。

14.2 八条最高级别红线(必须原样附给 AI)

  1. 禁反序列化/动态执行 —— unserialize() / eval() / assert() / preg_replace('/e') 全禁。
  2. 禁滥用绝对路径 —— 只用 LITELOG_ROOT / CONTENT_PATH / ADMIN_PATH / INCLUDE_PATH / DIR;禁用 $_SERVER['DOCUMENT_ROOT']。
  3. 渲染钩子禁中断 —— index_head / admin_head / admin_footer / log_list_* 等渲染钩子内禁 die() / exit() / header(),必须 return;。
  4. 上传白名单 + 强随机名 —— 扩展名白名单 + bin2hex(random_bytes(16)) 重命名;禁保留原始文件名。
  5. 数量原子扣减 —— 库存/积分必须 UPDATE ... SET stock=stock-1 WHERE id=? AND stock>0 + rowCount() 判定;禁先 SELECT 再 UPDATE。
  6. IN 强转 + 占位符 —— array_map('intval', $ids) + array_fill 生成占位符。
  7. PDO 不覆盖属性 —— 不得 setAttribute(ERRMODE_EXCEPTION / EMULATE_PREPARES)。
  8. AJAX 写操作必须有登录校验 —— 所有异步 POST 第一件事校验 !empty($_SESSION['uid'])。

14.3 附加约定(必须原样附给 AI)

  • 一目录一插件:目录名小写下划线,主文件名与目录名一致。
  • 不改核心文件:所有扩展点走 PluginRegistry + Hook。
  • 后台页面注册:用 PluginRegistry::addAdminPage + addAdminMenu,不要改 admin/index.php。
  • 前台路由注册:用 PluginRegistry::addFrontendRoute,不要改 dispatcher.php。
  • 后台视图不 include layout:核心已包好骨架。
  • 插件 option 加插件名前缀:如 friend_links_title,避免冲突。
  • 生命周期文件不输出内容:activate.php / deactivate.php / uninstall.php 里禁 echo。
  • 样式/JS 收敛到 assets/:避免大段内联 <style> / <script>。
  • 数据目录加 .htaccess:content/plugins/{插件名}/data/ 必须禁止 Web 访问。
  • URL 必须拼 BLOG_URL:兼容子目录部署。
  • 静态资源带 ?v=filemtime:防浏览器缓存旧文件。
  • 优先用核心模型:LogModel / SortModel / TagModel / CommentModel / UserModel,不要直查核心表。
  • 钩子注册在主文件或独立 hooks 文件:Hook::addAction($hook, callable, $priority)。
  • 钩子引用参数必须 &$data:log_save_before / comment_save_before 是引用传参。
  • 卸载自检:uninstall.php 后必须清表 + 清 option + 无残留。
  • 文件 UTF-8 无 BOM、LF 行尾,php -l 校验语法。
  • ⭐ v1.1 新增:所有 define() / function / Hook::addAction() 必须有守卫 + try/catch(见第十五章)。

14.4 指令模板示例

请按 LiteLog 插件开发规范(附上本文件)开发一个 {插件名} 插件: - 功能:{一段话描述} - 后台入口:侧边栏菜单名「{菜单名}」,页面 ?action={action} - 前台入口(可选):URL /{路径},对应页面 {描述} - 数据表:{列出需要的表} 严格遵守:三条硬性规则 + 八条最高级别红线 + 附加约定 + 第十五章「500 错误规避实战」全部条款。 不要修改任何核心文件。所有注册走 PluginRegistry,所有扩展走 Hook。 完成后给出完整文件列表和每个文件的完整代码。

十五、辅助材料:插件 500 错误规避实战(点赞送分插件)

本章为 v1.1 新增。 2026-10-10 开发「点赞送分」插件(forum_like_points)时,启用插件后整个后台 500。经逐条排查,共定位出 7 类致命写法。本章把它们全部固化,AI 读一次就能避开。

15.1 背景与症状

  • 症状:插件启用后,访问任何后台页面都 500(包括「插件列表」页本身,导致用户连停用都做不到,只能进数据库或文件系统手动处理)。
  • 根因:admin/register.php 在每次后台请求都被核心加载;该文件里任意 fatal error 会瞬间击垮整个后台。同时主文件/视图/生命周期文件里有若干「只有恰好在特定时机触发才炸」的写法,一旦命中也是 500。

15.2 七宗罪:致命的七类写法

罪 1 ★★★★★:admin/register.php 无 try/catch —— 后台 500 的第一嫌疑犯

为什么致命:该文件每次后台请求都加载。里面任何 fatal(方法不存在、参数错误、类未定义)→ 整个后台 500,用户连插件列表都进不去,无法自助停用。

错误示范:

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

PluginRegistry::addAdminMenu([...]);   // ← 若方法名/参数不符,这里直接炸
PluginRegistry::addAdminPage('xxx', [
    'title'    => '...',
    'assets'   => [...],                // ← assets 字段兼容性差,某些版本报错
]);

正确示范(v1.1 强制):

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

if (!class_exists('PluginRegistry')) return;   // ← 前置守卫

try {
    PluginRegistry::addAdminMenu([...]);
    PluginRegistry::addAdminPage('xxx', [
        'title'    => '...',
        'callback' => function () { include __DIR__ . '/pages/settings.php'; },
    ]);
} catch (Throwable $e) {                        // ← 兜底:写日志、不炸后台
    @file_put_contents(
        CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] {插件名} admin register: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX
    );
}

AI 记忆口诀:「admin/register.php 不包 try,后台必炸。」


罪 2 ★★★★:主文件顶层 define() / function 无守卫

为什么致命:主文件可能被 include 多次(生命周期钩子、前端渲染、后台请求、AJAX 端点等路径都会触发加载)。无守卫的 define('X', ...) 或 function Xxx() 第二次执行会:

  • define() → Constant already defined(PHP 8 起为 Warning,部分环境升级为 Error);
  • function Xxx() → Cannot redeclare Xxx()(致命错误,立即 500)。

错误示范:

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

define('FLP_VER', '1.0.0');                 // ← 重复加载即报错
function FLP_isEnabled() { /* ... */ }      // ← 重复加载直接 fatal

正确示范(v1.1 强制):

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

if (!defined('FLP_VER'))          define('FLP_VER', '1.0.0');
if (!defined('FLP_MAX_PER_LIKE')) define('FLP_MAX_PER_LIKE', 1000);

if (!function_exists('FLP_isEnabled')) {
    function FLP_isEnabled() { /* ... */ }
}
if (!function_exists('FLP_choices')) {
    function FLP_choices() { /* ... */ }
}
// ……每个函数都必须包

AI 记忆口诀:「define 判 defined,function 判 exists,一个都不能少。」


罪 3 ★★★★:Option::get() 第二参数传 null

为什么致命:核心 Option::get() 很可能声明为 string $default = ''(严格类型)。传 null 会直接 TypeError → fatal。同时,Option::get('key', null) 的语义也模糊——空串和 null 的判定差异会让「未配置」判断出错。

错误示范:

$v = Option::get('forum_like_points_enabled', null);   // ← TypeError
if ($v === null || $v === '') { /* ... */ }            // ← 语义混杂

正确示范(v1.1 强制):

// 统一用空串作默认值
$v = Option::get('forum_like_points_enabled', '');
if ($v === '') { /* 未配置 */ }

对于需要区分「未设置」和「设置为空串」的极端情况,用 array_key_exists 查核心 option 表,但不要传 null。

AI 记忆口诀:「Option::get 的第二个参数永远写 '',永远不写 null。」


罪 4 ★★★:PluginRegistry::addAdminPage 的 assets 字段兼容性

为什么危险:assets 字段是后来版本添加的扩展。老内核版本可能不认识这个键,或者期望的数组结构不同(如 ['css' => '单个 URL'] vs ['css' => [URL数组]]),一旦结构不匹配,在核心解析时就会抛错 → 后台 500。

风险写法:

PluginRegistry::addAdminPage('xxx', [
    'title'    => '...',
    'callback' => function () { include __DIR__ . '/pages/settings.php'; },
    'assets'   => [                          // ← 兼容性风险点
        'css' => [BLOG_URL . '.../admin.css'],
        'js'  => [BLOG_URL . '.../admin.js'],
    ],
]);

保险写法(v1.1 推荐):不用 assets 字段,把资源加载放到视图文件里直接输出:

// admin/register.php —— 只注册菜单和页面
PluginRegistry::addAdminPage('xxx', [
    'title'    => '...',
    'callback' => function () { include __DIR__ . '/pages/settings.php'; },
]);

// admin/pages/settings.php —— 视图内自行输出 CSS / JS
<?php
$cssVer = @filemtime(CONTENT_PATH . 'plugins/xxx/admin/assets/admin.css') ?: 1;
$jsVer  = @filemtime(CONTENT_PATH . 'plugins/xxx/admin/assets/admin.js')  ?: 1;
?>
<link rel="stylesheet" href="<?= BLOG_URL ?>content/plugins/xxx/admin/assets/admin.css?v=<?= (int)$cssVer ?>">
<!-- ... 页面内容 ... -->
<script src="<?= BLOG_URL ?>content/plugins/xxx/admin/assets/admin.js?v=<?= (int)$jsVer ?>"></script>

AI 记忆口诀:「后台资源别走 assets 字段,视图里自己 <link> / <script> 最稳。」


罪 5 ★★★★:生命周期文件 activate.php DDL 用 COMMENT / 特殊语法

为什么危险:不同 MySQL / MariaDB 版本对 COMMENT、COLLATE、特殊 ENGINE 的支持有差异。老版本对 COMMENT 内的中文字符集处理也可能出错。同时,如果 DDL 抛异常,activate.php 无 try/catch 就会把异常往上抛,核心记录日志但不会回滚启用状态——用户以为启用了,实际表没建好,之后所有查询都炸。

风险写法:

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
global $DB;

$DB->query("CREATE TABLE IF NOT EXISTS `...` (
    `user_id` INT UNSIGNED NOT NULL COMMENT '点赞人 uid',       -- ← COMMENT 兼容性差
    ...
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");  -- ← COLLATE 也偶发问题
// ← 没有 try/catch

保险写法(v1.1 强制):

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

try {
    global $DB;

    $DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "xxx` (
        `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        `user_id` INT UNSIGNED NOT NULL,
        `owner_id` INT UNSIGNED NOT NULL,
        `target_type` VARCHAR(20) NOT NULL DEFAULT '',
        `points` INT NOT NULL DEFAULT 0,
        `created_at` INT UNSIGNED NOT NULL DEFAULT 0,
        UNIQUE KEY `uniq_vote` (`user_id`, `target_type`, `target_id`),
        KEY `idx_user_time` (`user_id`, `created_at`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

    // 初始化默认配置(只在未设置时写)
    if (!class_exists('Option')) return;
    foreach ([
        'enabled'     => '1',
        'mode'        => 'off',
        'choices'     => '1,2,5,10',
        'daily_limit' => '10',
    ] as $key => $val) {
        $optKey = 'xxx_' . $key;
        $cur = Option::get($optKey, '');
        if ($cur === '') Option::set($optKey, $val);
    }
} catch (Throwable $e) {
    @file_put_contents(
        CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] xxx activate: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX
    );
}

关键三条:

  1. 整个 activate.php 用 try/catch 包住;
  2. DDL 去掉 COMMENT(列注释不参与业务,删掉零损失);
  3. COLLATE 也可省(CHARSET=utf8mb4 已够,缺省排序规则够用)。

AI 记忆口诀:「activate 整段包 try,DDL 去 COMMENT 去 COLLATE。」


罪 6 ★★★★:后台视图直接调用插件函数,无 function_exists 守卫

为什么致命:视图文件是独立 include 的。若主文件因故未加载(前置 fatal、路径错误、被核心跳过等),视图里直接调 FLP_isEnabled() 就会 Call to undefined function → fatal。

错误示范:

// admin/pages/settings.php
<?php if (!defined('LITELOG_ROOT')) exit('error!'); ?>
<h1><?= FLP_isEnabled() ? '已启用' : '未启用' ?></h1>   <!-- 主文件没加载就炸 -->

正确示范(v1.1 强制):

// admin/pages/settings.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');

// ★ 前置守卫:插件主文件定义的函数必须检测存在性
if (!function_exists('FLP_isEnabled') || !function_exists('FLP_table')) {
    echo '<div class="card"><div class="card-title">插件未正确加载</div>'
       . '<p style="color:#b91c1c">主文件 xxx.php 未加载,请检查插件目录与激活状态。</p></div>';
    return;
}
?>
<h1><?= FLP_isEnabled() ? '已启用' : '未启用' ?></h1>

AI 记忆口诀:「视图先 function_exists 守卫,检测不过就优雅退场。」


罪 7 ★★★:Hook::addAction() 无 try/catch

为什么危险:Hook::addAction() 里如果传入了闭包(function () { ... }),在注册阶段可能因闭包语法、$this 绑定、变量作用域等问题抛错;同时如果 Hook 类本身还未加载(极少见但可能),直接调用就是 fatal。包一层 try/catch 成本极低、收益极高。

错误示范:

Hook::addAction('dispatch_start', 'FLP_injectForumScript');   // ← 若 Hook 类未加载即 fatal

正确示范(v1.1 强制):

if (class_exists('Hook')) {
    try {
        Hook::addAction('dispatch_start', 'FLP_injectForumScript');
        Hook::addAction('public_action_xxx', 'FLP_handleXxx');
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] xxx hook register: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

AI 记忆口诀:「Hook 注册也包 try,捕获异常不炸页。」


15.3 排查 500 的标准流程

一旦发现「启用插件后后台 500」:

第 1 步 — 先停用插件(恢复后台)

如果连插件列表都进不去,有两种方式:

  • 数据库方式:UPDATE {前缀}option SET option_value = ... WHERE option_name = 'active_plugins';(把插件名从激活列表移除);
  • 文件方式:删除或改名 content/plugins/{插件名}/admin/register.php,后台即可恢复。

第 2 步 — 临时打开错误显示

在 init.php 最前面(或 index.php)临时加:

ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);

刷新后台,错误会直接显示在页面上(含文件名 + 行号)。

第 3 步 — 查三处日志

  1. PHP 错误日志:php_errors.log / nginx error log / php-fpm error log;
  2. 插件日志:content/backup/plugin-error.log(本规范要求每个 catch 都写这里);
  3. Web 服务器日志:nginx / apache access + error log。

第 4 步 — 对照报错关键词

报错关键词原因对应条款
Call to undefined method PluginRegistry::xxx核心方法名不符罪 1
Call to undefined function xxx_yyy()主文件未加载 / 视图守卫缺失罪 2 + 罪 6
Constant already defined / Cannot redeclare xxx()主文件顶层 define / function 无守卫罪 2
TypeError: ...: Argument #2 ($default) must be of type string, null givenOption::get 传了 null罪 3
SQLSTATE[HY000]: ... Unknown columnDDL 未成功执行罪 5
SQLSTATE[42000]: ... near 'COMMENT ...'DDL 用了不兼容语法罪 5
syntax error, unexpected 'xxx'语法错误 / BOM 头—
Maximum function nesting level / Allowed memory size exhausted死循环 / 钩子重复触发—

第 5 步 — 逐条消罪

按 15.2 的 7 宗罪从上往下改,改完 php -l 校验,重启插件,验证。

15.4 安全插件骨架(v1.1 推荐模板)

把下面这份骨架直接抄来用,90% 的 500 都能避开:

<?php
/**
 * Plugin Name: {插件名}
 * Description: {功能描述}
 * Version: 1.0.0
 * Author: {作者}
 * Author URI: {主页}
 */

if (!defined('LITELOG_ROOT')) exit('error!');

/* ---- 常量守卫 ---- */
if (!defined('{前缀}_VER'))          define('{前缀}_VER', '1.0.0');
if (!defined('{前缀}_MAX_PER_LIKE')) define('{前缀}_MAX_PER_LIKE', 1000);

/* ---- 所有函数加 function_exists 守卫 ---- */
if (!function_exists('{前缀}_conf')) {
    function {前缀}_conf($key, $default = '') {
        if (!class_exists('Option')) return $default;
        try {
            $v = Option::get('{插件名}_' . $key, '');   // ★ 第二参数永远用 ''
        } catch (Throwable $e) {
            return $default;
        }
        return $v !== '' ? $v : $default;
    }
}

if (!function_exists('{前缀}_isEnabled')) {
    function {前缀}_isEnabled() { /* ... */ }
}

// …… 每个函数都这样包

/* ---- Hook 注册必须包 try/catch ---- */
if (class_exists('Hook')) {
    try {
        Hook::addAction('public_action_{插件名}_xxx', '{前缀}_handleXxx');
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] {插件名} hook register: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}
<?php
// activate.php —— 整段包 try/catch + 无 COMMENT/COLLATE
if (!defined('LITELOG_ROOT')) exit('error!');

try {
    global $DB;
    $DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "{插件名}_xxx` (
        `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        `created_at` INT UNSIGNED NOT NULL DEFAULT 0
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

    if (class_exists('Option')) {
        foreach ([...默认值...] as $key => $val) {
            if (Option::get('{插件名}_' . $key, '') === '') {
                Option::set('{插件名}_' . $key, $val);
            }
        }
    }
} catch (Throwable $e) {
    @file_put_contents(
        CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] {插件名} activate: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX
    );
}
<?php
// admin/register.php —— 前置守卫 + 整段 try/catch
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;

try {
    PluginRegistry::addAdminMenu([
        'id'       => '{插件名}',
        'action'   => '{插件名}',
        'title'    => '{菜单名}',
        'icon'     => 'settings',
        'priority' => 30,
    ]);
    PluginRegistry::addAdminPage('{插件名}', [
        'title'    => '{页面标题}',
        'callback' => function () {
            include __DIR__ . '/pages/settings.php';
        },
    ]);
} catch (Throwable $e) {
    @file_put_contents(
        CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] {插件名} admin register: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX
    );
}
<?php
// admin/pages/settings.php —— 顶部 function_exists 守卫
if (!defined('LITELOG_ROOT')) exit('error!');

if (!function_exists('{前缀}_isEnabled')) {
    echo '<div class="card"><div class="card-title">插件未正确加载</div>'
       . '<p style="color:#b91c1c">主文件未加载,请检查插件目录与激活状态。</p></div>';
    return;
}

// 资源在视图内直接输出(不用 assets 字段)
$cssVer = @filemtime(CONTENT_PATH . 'plugins/{插件名}/assets/admin.css') ?: 1;
$jsVer  = @filemtime(CONTENT_PATH . 'plugins/{插件名}/assets/admin.js')  ?: 1;
?>
<link rel="stylesheet" href="<?= BLOG_URL ?>content/plugins/{插件名}/assets/admin.css?v=<?= (int)$cssVer ?>">
<!-- ... 页面内容 ... -->
<script src="<?= BLOG_URL ?>content/plugins/{插件名}/assets/admin.js?v=<?= (int)$jsVer ?>"></script>

15.5 一票通过自检清单(v1.1 版)

写任何插件前,先对照下面这份「500 免疫」自检清单,全部打钩再提交:

  • [ ] admin/register.php 是否 class_exists('PluginRegistry') 守卫 + try/catch 包裹?
  • [ ] 主文件 每个 define() 是否 if (!defined(...)) 守卫?
  • [ ] 主文件 每个 function xxx() 是否 if (!function_exists(...)) 守卫?
  • [ ] 所有 Option::get() 的第二参数是否为 ''(不是 null)?
  • [ ] activate.php / deactivate.php / uninstall.php 是否整段 try/catch?
  • [ ] DDL 语句 是否没有 COMMENT / COLLATE?
  • [ ] 后台视图 顶部是否有 function_exists() 守卫(检测插件主文件函数)?
  • [ ] 所有 Hook::addAction() 是否包在 class_exists('Hook') + try/catch 里?
  • [ ] 插件自定义函数 是否统一加插件名前缀(如 FLP_),避免与核心/其它插件冲突?
  • [ ] 所有 catch 块 是否都 file_put_contents 到 content/backup/plugin-error.log?
  • [ ] php -l 是否所有文件都过?
  • [ ] 激活 → 停用 → 再激活 → 卸载 全流程是否无异常?

AI 记忆总口诀:

「register 包 try,define 判 defined,function 判 exists,Option 用空串,activate 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try。」

掌握这 8 条,99% 的插件 500 都能规避。


本规范基于 LiteLog v1.0.0 内核代码整理,v1.1 补充第十五章「500 错误规避实战」。若内核有更新,请同步更新「钩子系统全览」与「PluginRegistry 注册表」两章,并把新的 500 案例追加到第十五章。

登录 或 后台登录 后参与讨论