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 下指令时,请把本文件完整附上。
目录
- 总则:三条硬性安全规则
- 最高级别红线
- 插件目录结构
- 插件主文件与头注释
- 生命周期文件
- PluginRegistry 注册表
- 钩子系统全览
- 数据库与模型
- 视图与模板
- 资源文件
- 小工具与首页模式扩展
- 常见坑与红线清单
- 开发流程与验证
- 给 AI 的指令模板
- 辅助材料:插件 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_request | admin/index.php 登录校验后 | $action | 每次后台请求都会触发 |
admin_head | admin/views/layout.php <head> 内 | 无 | 注入后台 CSS / meta |
admin_footer | admin/views/layout.php </body> 前 | 无 | 注入后台 JS |
admin_menu | layout.php 侧边栏「扩展」区 | $activeKey | 在侧边栏插入菜单(不推荐,用 PluginRegistry::addAdminMenu 更好) |
admin_dashboard_widgets | views/dashboard.php 末尾 | 无 | 概览页扩展区 |
admin_log_edit_sidebar | views/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_start | Dispatcher::dispatch() 开头 | 无 | 路由分发开始 |
dispatch_end | Dispatcher::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_head | LogController::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_before | CommentController::handlePost() | &$data | 可改 $data(如加 uid) |
comment_save_after | 评论插入后 | $cid, $data | — |
comment_post | 评论提交完成后 | $data | — |
public_action_comment_post | ?action=comment_post | 无 | 评论提交入口 |
7.5 扩展点钩子(供插件注册)
| 钩子名 | 触发点 | 说明 |
|---|---|---|
register_widgets | WidgetRegistry::init() | 注册自定义小工具 |
register_homepage_modes | HomepageModeRegistry::init() | 注册首页模式 |
permalink_build_log_url | Url::log() | 自定义文章 URL(写入 $GLOBALS['__permalink_url_log']) |
permalink_build_sort_url | Url::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)。推荐做法:
- 检测字段是否存在(
SHOW COLUMNS FROM ... LIKE ...)后再 ALTER; - 用版本号记录(
Option::set('friend_links_db_version', '2')),只在版本不匹配时执行迁移。
8.3 使用核心模型
LiteLog 已有模型:
| 模型 | 主要方法 |
|---|---|
LogModel | query()、getOne()、getByAlias()、latest()、prev()、next()、add()、update()、delete()、addView() |
SortModel | all()、getOne()、getByAlias()、add()、update()、delete()、countInSort() |
TagModel | all()、getByName()、getByGid()、getGidsByTag()、attach()、detach() |
CommentModel | getOne()、byLog()、byLogPaginated()、buildTree()、latest()、add()、delete()、countByLog() |
UserModel | getOne()、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 安全
- 路径穿越 —— 文件名一律过
basename()(规则 1)。 - SQL 注入 —— 必须占位符绑定或
(int)强转(规则 2)。 - XSS —— 输出必须过
h()(规则 3)。 - IDOR —— 按 ID 操作(编辑/删除)必须校验资源存在;ID 一律
(int)强转。 - 上传 —— 扩展名白名单 + 真实图片校验 + 随机文件名(红线 4)。
- 开放重定向 ——
redirect($url)只允许本站 URL,禁止用未过滤的用户输入。
12.2 部署兼容
- 不要硬编码路径 —— URL 必须拼
BLOG_URL,文件路径必须拼LITELOG_ROOT/CONTENT_PATH/DIR。 - 子目录部署 —— 假设站点可能在
https://example.com/blog/部署,所有 URL 拼接都要能兼容。 - 不区分大小写文件系统 —— 插件目录名保持小写下划线,避免在 Linux 上跑挂。
12.3 结构规范
- 一目录一插件 —— 目录名小写下划线,与主文件名一致。
- 不留临时文件 ——
.bak/.tmp/.old/.revert一律清理。 - 不改核心文件 —— 所有扩展点走
PluginRegistry+Hook。 - 后台视图不 include layout —— 核心已包好骨架,只需输出主体。
- 插件自己的 option 加插件名前缀 —— 避免和核心/其它插件冲突。
- 生命周期文件不输出内容 ——
activate.php/deactivate.php/uninstall.php里禁止echo。 - 所有文件 UTF-8 无 BOM,LF 行尾,用
php -l校验语法。
12.4 性能
- 避免 N+1 查询 —— 批量取 ID 后一次性
IN (...)补字段。 - 静态缓存 —— 同一请求内相同查询用
static变量缓存。 - 钩子轻量化 —— 频繁触发的钩子(
log_item_meta每篇文章一次、comment_save_before每条评论一次)里禁止重查询。 - 避免在
log_list_query/admin_request等高频钩子里做同步 IO。
12.5 兼容性
$DB是全局 —— 在函数里用必须先global $DB;。$data引用参数 —— 修改log_save_before/comment_save_before的参数必须function (&$data, ...)。redirect()后必须 exit —— 核心redirect()已自带exit,但自定义跳转要自己加。$_SESSION['uid']是登录标识 —— 判断登录用!empty($_SESSION['uid']),不要用其它字段。
12.6 卸载自检
uninstall.php 执行后必须自查:
- 插件自建的表已删(
DROP TABLE IF EXISTS)。 - 核心
option表里本插件的键已清(DELETE FROM option WHERE option_name LIKE 'friend_links_%')。 - 用户表中本插件写入的字段已清(如果修改过
user表结构)。 - 插件目录里的
data/目录如果含敏感文件,需要在uninstall.php里清理(但不删插件目录本身,那是用户的责任)。
十三、开发流程与验证
13.1 标准流程
- 建目录骨架:
B40
- 写主文件(头注释 + 类定义)。
- 写
activate.php(建表)。 - 写
admin/register.php(注册后台菜单 + 页面)。 - 写后台视图(
admin/pages/*.php)。 - (可选)写
frontend/routes.php+ 前台视图。 - (可选)写钩子回调(
Hook::addAction(...))。 - 启用插件:后台 → 插件 → 启用。观察是否建表成功。
- 验证后台:侧边栏是否出现菜单,点击是否正常渲染。
- (如有前台)验证前台 URL。
- 停用 / 启用循环测试:确认无异常。
- 卸载测试:确认表和数据清理干净。
13.2 验证清单
| 项 | 方法 |
|---|---|
| 语法正确 | php -l content/plugins/my_plugin/*.php |
| 头注释完整 | 后台插件列表页显示 Plugin Name 而非目录名 |
| 建表成功 | 数据库里能看到 {前缀}my_plugin_xxx 表 |
| 后台菜单出现 | 侧边栏「扩展」区多了菜单项 |
| 后台页面渲染 | 点菜单能看到注册的页面内容 |
| 无副作用 | 访问首页 / 文章页 / 评论页,页面无异常输出 |
| 卸载干净 | 卸载后 SHOW TABLES LIKE '{前缀}my_plugin%' 为空 |
| 无残留 option | SELECT * FROM {前缀}option WHERE option_name LIKE 'my_plugin_%' 为空 |
13.3 常见错误定位
| 症状 | 可能原因 |
|---|---|
| 后台菜单不出现 | admin/register.php 里没有 addAdminMenu;或插件未启用 |
| 点击菜单白屏 | callback 里抛异常但没输出;检查 content/backup/plugin-error.log |
| 页面出现 404 | action 未注册,或 addAdminPage 拼写错误 |
| 数据表未创建 | activate.php 语法错误 / SQL 错误;看 plugin-error.log |
| 前台 URL 404 | frontend/routes.php 正则没匹配;或在 ?action=xxx 时不会走插件路由 |
| 资源 404 | URL 没拼 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)
- 禁反序列化/动态执行 ——
unserialize()/eval()/assert()/preg_replace('/e')全禁。 - 禁滥用绝对路径 —— 只用
LITELOG_ROOT/CONTENT_PATH/ADMIN_PATH/INCLUDE_PATH/DIR;禁用$_SERVER['DOCUMENT_ROOT']。 - 渲染钩子禁中断 ——
index_head/admin_head/admin_footer/log_list_*等渲染钩子内禁die()/exit()/header(),必须return;。 - 上传白名单 + 强随机名 —— 扩展名白名单 +
bin2hex(random_bytes(16))重命名;禁保留原始文件名。 - 数量原子扣减 —— 库存/积分必须
UPDATE ... SET stock=stock-1 WHERE id=? AND stock>0+rowCount()判定;禁先 SELECT 再 UPDATE。 - IN 强转 + 占位符 ——
array_map('intval', $ids)+array_fill生成占位符。 - PDO 不覆盖属性 —— 不得
setAttribute(ERRMODE_EXCEPTION / EMULATE_PREPARES)。 - 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
);
}
关键三条:
- 整个
activate.php用 try/catch 包住; - DDL 去掉
COMMENT(列注释不参与业务,删掉零损失); 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 步 — 查三处日志
- PHP 错误日志:
php_errors.log/ nginx error log / php-fpm error log; - 插件日志:
content/backup/plugin-error.log(本规范要求每个 catch 都写这里); - 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 given | Option::get 传了 null | 罪 3 |
SQLSTATE[HY000]: ... Unknown column | DDL 未成功执行 | 罪 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 案例追加到第十五章。