LiteLog 插件开发规范(v2.0 · 实测修订版)
版本:2.0(2026-10-11)
性质:基于 LiteLog v1.0.0 内核源码实测修订。修正 v1.2 中若干与内核不符的描述,新增「视图 / 资源注入 / 生命周期 / 调试方法」专章。
自包含性:任何 AI 拿到本文件后,无需再阅读核心代码即可完成插件开发。
适用范围:LiteLog 内核 content/plugins/ 目录下所有插件
核实基准:LiteLog v1.0.0 内核(2026-10-10 导出)
给 AI 下指令时,请把本文件完整附上。
📌 给 AI 的必读(0 号指令)
在你写任何 LiteLog 插件代码之前,请按顺序读完以下章节:
- 第 1 章《500 免疫》 —— 违反任意一条,插件启用后整个后台会 500,用户连停用都做不到。
- 第 2 章《三条硬性安全规则》 —— 安全底线,违反即不合格。
- 第 3 章《八条最高级别红线》 —— 一票否决项。
- 第 4 章《目录结构与自动加载真相》 —— ★ v2.0 新增:明确哪些文件被自动加载、哪些不是。
- 第 10 章《视图与模板》 —— ★ v2.0 重写:前台视图必须 include 主题 header/footer,绝对不能自建 HTML 骨架。
- 第 11 章《资源加载》 —— ★ v2.0 重写:CSS/JS 通过 Hook 注入,不是视图里直接 link。
之后,用第 5-9 章查「主文件 / 生命周期 / 注册表 / 钩子 / 数据库」的具体写法;用第 14 章《API 参考》查符号、签名、字段;用第 17 章《完整最小骨架》直接抄写模板。
最后,用第 16 章《调试方法》排错,用第 18 章《交付前自检清单》逐条打钩,全部通过再交付。
⚠️ v2.0 核心警告:本规范中标注「★ 实测」的条目,是基于内核源码逐行核对得出的结论,与 v1.2 中基于传闻的描述可能不同。当 v2.0 与 v1.2 冲突时,以 v2.0 为准。
目录
- 第 1 章:★ 500 错误免疫(最高优先级)
- 第 2 章:三条硬性安全规则
- 第 3 章:八条最高级别红线
- 第 4 章:★ 目录结构与自动加载真相
- 第 5 章:插件主文件与头注释
- 第 6 章:★ 生命周期(callback 回调)
- 第 7 章:★ PluginRegistry 注册表
- 第 8 章:★ 钩子系统全览(内核实测)
- 第 9 章:★ 数据库与模型
- 第 10 章:★ 视图与模板
- 第 11 章:★ 资源加载
- 第 12 章:小工具与首页模式扩展
- 第 13 章:加载时机 / exit 边界 / 命名约定
- 第 14 章:★ API 参考(实测)
- 第 15 章:常见坑清单
- 第 16 章:★ 调试方法与验证流程
- 第 17 章:★ 完整最小骨架(可直接抄写)
- 第 18 章:交付前自检清单
- 第 19 章:★ 开发经验教训(实测总结)
- 第 20 章:给 AI 的指令模板
第 1 章:★ 500 错误免疫(最高优先级)
为什么放第一:一旦发生,整个后台 500,用户连「停用插件」都点不进去,只能进数据库或文件系统手动处理。违反本章任意一条,插件即不合格。
1.1 症状描述
- 插件启用后,访问任何后台页面都 500(包括「插件列表」页本身)。
- 只能通过数据库改
option表或删除content/plugins/{插件名}/目录来恢复。
根因:内核 loadActivePlugins() 在每次前台/后台请求都被调用;插件主文件里任意 fatal error 会击垮整个站点。
1.2 七宗罪
罪 1 ★★★★★:主文件顶层 define() / function 无守卫
为什么炸:主文件可能被 include 多次。第二次执行无守卫的 define / function 会:
define()→Constant already defined(PHP 8 起可能升级为 Error)function xxx()→Cannot redeclare xxx()(致命错误,立即 500)
错误:
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
define('ICG_VER', '1.0.0'); // ← 重复加载即报错
function ICG_isEnabled() { /* ... */ } // ← 重复加载直接 fatal
正确:
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!defined('ICG_VER')) define('ICG_VER', '1.0.0');
if (!function_exists('ICG_isEnabled')) {
function ICG_isEnabled() { /* ... */ }
}
// ……每个函数都必须包
口诀:「define 判 defined,function 判 exists,一个都不能少。」
罪 2 ★★★★:Option::get() 第二参数传 null
内核 Option::get($name, $default = '') 严格类型。传 null 在 PHP 8 会触发警告,某些扩展路径会 TypeError。
正确:
$v = Option::get('icon_generator_enabled', ''); // ★ 永远用空串
if ($v === '') { /* 未配置 */ }
口诀:「Option::get 第二个参数永远写 ''。」
罪 3 ★★★★:生命周期 DDL 用 COMMENT / COLLATE / 无 try/catch
为什么炸:不同 MySQL 版本对 COMMENT / COLLATE / 特殊 ENGINE 支持有差异。DDL 抛异常时若 callback_init() 无 try/catch,用户以为启用了但表没建好。
正确:
function callback_init() {
try {
global $DB;
$DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "my_items` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL DEFAULT '',
`created_at` INT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin callback_init: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
}
关键三条:
- 整个
callback_init()用 try/catch 包住 - DDL 去掉
COMMENT(列注释零损失) COLLATE也可省(CHARSET=utf8mb4已够)
罪 4 ★★★★:视图直接调用插件函数,无 function_exists 守卫
为什么炸:视图文件是独立 include 的。若主文件因故未加载(前置 fatal、路径错误、被内核跳过),视图里直接调 ICG_isEnabled() 就是 Call to undefined function → fatal。
错误:
// views/generator.php
<?php if (!defined('LITELOG_ROOT')) exit('error!'); ?>
<h1><?= ICG_isEnabled() ? '已启用' : '未启用' ?></h1>
正确:
// views/generator.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!function_exists('ICG_isEnabled')) {
echo '<div class="error">插件未正确加载</div>';
return;
}
?>
<h1><?= ICG_isEnabled() ? '已启用' : '未启用' ?></h1>
口诀:「视图先 function_exists 守卫。」
罪 5 ★★★:Hook::addAction() 无 try/catch
if (class_exists('Hook')) {
try {
Hook::addAction('template_head', 'ICG_injectCss');
Hook::addAction('dispatch_plugin_route', 'ICG_handleRoute');
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin hook register: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
}
罪 6 ★★★:PluginRegistry 相关调用无 class_exists 守卫
PluginRegistry 只在后台请求时被内核加载(admin/index.php 里 require)。若插件主文件在前台被加载时调用了 PluginRegistry,class_exists('PluginRegistry') 会返回 false——此时不做守卫直接调用会 fatal。
正确:
if (class_exists('PluginRegistry')) {
try {
PluginRegistry::addFrontendRoute('icon/?', function ($m) { ... });
} catch (Throwable $e) { /* 写日志 */ }
}
罪 7 ★★★:路由注册文件(frontend/routes.php)被主文件忘记 require
★ v2.0 新增:内核 不会自动加载 frontend/routes.php。如果主文件里没写 require_once DIR . '/frontend/routes.php';,路由永远不会注册。
正确:
// 在 icon_generator.php 主文件末尾
if (class_exists('PluginRegistry')) {
try {
$__routes = __DIR__ . '/frontend/routes.php';
if (is_file($__routes)) require_once $__routes;
} catch (Throwable $e) { /* 写日志 */ }
}
1.3 七条记忆口诀
**「define 判 defined, function 判 exists, Option 用空串, callback 整段包, DDL 去 COMMENT, 视图先守卫, Hook 也包 try。」**
1.4 500 排查标准流程
第 1 步 — 先停用插件(恢复后台)
- 文件方式:把
content/plugins/{插件名}/改名(加.bak后缀),后台即可恢复 - 数据库方式:
UPDATE {前缀}option SET option_value = REPLACE(option_value, '{插件名},', '') WHERE option_name = 'active_plugins';
第 2 步 — 临时打开错误显示
在 init.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 - Web 服务器日志
第 4 步 — 对照报错关键词
| 报错关键词 | 原因 | 对应条款 |
|---|---|---|
Cannot redeclare xxx() | 主文件顶层 function 无守卫 | 罪 1 |
Constant already defined | 主文件顶层 define 无守卫 | 罪 1 |
Call to undefined function ICG_xxx() | 主文件未加载 / 视图守卫缺失 | 罪 4 |
Call to undefined class PluginRegistry | 在前台路径调用了 PluginRegistry | 罪 6 |
SQLSTATE[42000]: ... near 'COMMENT ...' | DDL 用了不兼容语法 | 罪 3 |
syntax error, unexpected 'xxx' | 语法错误 / BOM 头 | — |
第 2 章:三条硬性安全规则
规则 1 — 文件操作,无 basename 不落盘
$safeName = basename($userInput);
if ($safeName === '' || $safeName !== $userInput) {
return false;
}
$safePath = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $safeName;
if (is_file($safePath)) unlink($safePath);
铁律:凡是文件名,一律先过 basename(),不允许直接拼用户输入。
规则 2 — 数据库查询,无预处理不执行
// ✅ 正确
$row = $DB->fetchOne(
"SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=?",
[(int)$gid]
);
// ✅ 批量 IN 必须 intval + 占位符
$ids = array_map('intval', $ids);
if ($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");
规则 3 — 输出字符串必须转义
// ✅
echo '<h1>' . h($log['title']) . '</h1>';
echo '<a href="' . h($url) . '">' . h($name) . '</a>';
// ❌
echo '<h1>' . $log['title'] . '</h1>';
铁律:凡输出到页面的字符串,绝不能绕过 h()。富文本需展示 HTML 时,必须先经过白名单过滤或明确信任来源(管理员输入)。
第 3 章:八条最高级别红线
红线 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'] 直接拼路径。
红线 3 — 渲染钩子内禁止中断页面
所有渲染类钩子(template_head、body_end、admin_head、admin_footer、log_item_meta 等)内绝不能使用 die() / exit() / header()。必须 return;。
红线 4 — 上传文件必须白名单 + 随机文件名
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg','png','gif'], true)) { return; }
$filename = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
上传前必须校验真实性:图片用 getimagesize(),其它用 MIME 白名单。
红线 5 — 数量扣减必须原子更新
$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);
红线 7 — PDO 属性不得覆盖
内核 Database 已统一设置 PDO::ATTR_ERRMODE = ERRMODE_EXCEPTION + PDO::ATTR_EMULATE_PREPARES = false。插件不得调用 setAttribute() 覆盖这两个属性。
红线 8 — AJAX 写操作必须有登录校验
if (empty($_SESSION['uid'])) {
echo json_encode(['ok' => false, 'msg' => '未登录']);
exit;
}
涉及管理员操作的,还要校验 $_SESSION['role'] === 'admin'。
第 4 章:★ 目录结构与自动加载真相
4.1 目录结构
content/plugins/{插件名}/
├── {插件名}.php ← 【必须】主文件 + 插件头注释
├── {插件名}_callback.php ← 【可选】生命周期回调(建表 / 清理)
├── admin/
│ ├── register.php ← 【可选】后台菜单/页面注册(需主文件主动 require)
│ ├── pages/ ← 后台视图(PHP 文件)
│ └── assets/ ← 后台专属 CSS/JS
├── frontend/
│ └── routes.php ← 【可选】前台路由注册(需主文件主动 require)
├── views/ ← 【可选】前台视图(前后台共用)
├── assets/ ← 【可选】插件静态资源(Web 直接可访问)
└── data/ ← 【可选】插件独立数据目录(建议加 .htaccess)
4.2 ★ 内核真实自动加载行为
内核 loadActivePlugins() 只做这一件事:
// include/lib/function.common.php 实测源码
function loadActivePlugins() {
$active = Option::get('active_plugins', '');
if (!$active) return;
foreach (explode(',', $active) as $p) {
$p = trim($p);
if (!$p) continue;
$file = CONTENT_PATH . 'plugins/' . $p . '/' . $p . '.php';
if (file_exists($file)) require_once $file; // ← 只加载主文件
}
}
结论:
| 文件 | 内核是否自动加载 | 你需要做什么 |
|---|---|---|
{插件名}.php | ✅ 是 | 无 |
{插件名}_callback.php | ❌ 否 | 内核在启用/停用时主动 require(见第 6 章) |
frontend/routes.php | ❌ 否 | 主文件里必须 require_once DIR . '/frontend/routes.php'; |
admin/register.php | ❌ 否 | 主文件里必须 require_once DIR . '/admin/register.php'; |
views/*.php | ❌ 否 | 由路由回调 / 控制器主动 include |
assets/* | ❌ 否 | 通过 Hook 或视图 <link> 引入 |
★ v2.0 核心修正:v1.2 中说"admin/register.php 和 frontend/routes.php 每次请求自动加载"——这是错误的。内核只加载主文件。这两个文件必须在主文件顶层主动 require。
4.3 主文件里正确的加载顺序
// icon_generator.php 主文件末尾
require_once __DIR__ . '/frontend/routes.php'; // 注册前台路由
// require_once __DIR__ . '/admin/register.php'; // 注册后台菜单(如果用)
// 生命周期回调不用主文件 require,内核在启用时自己 require
顺序建议:常量 → 函数 → Hook 注册 → require 子文件。
4.4 禁止
- 在插件目录外(核心
admin/、include/目录)写任何文件。 - 修改核心文件——所有扩展点都必须走
PluginRegistry+Hook。 - 遗留
.bak/.tmp/.old/.revert等临时文件。
第 5 章:插件主文件与头注释
主文件 content/plugins/{插件名}/{插件名}.php 是插件的唯一自动加载入口。
5.1 标准头注释
<?php
/**
* Plugin Name: 友情链接
* Description: 管理友情链接,支持排序与前台展示
* Version: 1.0.0
* Author: 你的名字
* Author URI: https://example.com
*/
if (!defined('LITELOG_ROOT')) exit('error!');
头注释支持的字段(admin/helpers.php::parseHeader() 实测):
| 字段 | 说明 | 备选写法 |
|---|---|---|
Plugin Name | 显示名 | Name |
Description | 功能描述 | — |
Version | 版本号 | — |
Author | 作者 | — |
Author URI | 作者主页 | Author URL |
5.2 主文件的构成
主文件应该只做四件事:
- 常量守卫(
define+function) - 定义工具函数(配置读取、辅助函数)
- 注册 Hook(
Hook::addAction) require子文件(frontend/routes.php/admin/register.php)
禁止:
- 主文件顶层输出任何内容(不 echo、不 print、不 header)
- 主文件顶层执行 DB 写入(应在
callback_init()里做) - 主文件顶层做耗时的 IO
第 6 章:★ 生命周期(callback 回调)
★ v2.0 完全重写:内核不使用activate.php/deactivate.php/uninstall.php。真实的生命周期文件是{插件名}_callback.php。
6.1 内核实测行为
admin/index.php::plugin_toggle 分支的逻辑(实测):
case 'plugin_toggle':
$name = $_GET['name'] ?? '';
$callbackFile = CONTENT_PATH . 'plugins/' . $name . '/' . $name . '_callback.php';
if (in_array($name, $activeList, true)) {
// ---- 停用 ----
saveActivePlugins(array_diff($activeList, [$name]));
if (is_file($callbackFile)) {
require_once $callbackFile;
if (function_exists('callback_rm')) @callback_rm();
}
Hook::doAction('plugin_deactivate', $name);
} else {
// ---- 启用 ----
saveActivePlugins([...$activeList, $name]);
if (is_file($callbackFile)) {
require_once $callbackFile;
if (function_exists('callback_init')) @callback_init();
}
Hook::doAction('plugin_activate', $name);
}
结论:
| 时机 | 内核调用 | 你的回调文件 |
|---|---|---|
| 启用插件 | callback_init() | {插件名}_callback.php |
| 停用插件 | callback_rm() | 同上 |
| 升级插件 | callback_up()(内核当前未主动调用,可自己手动触发) | 同上 |
6.2 {插件名}_callback.php 模板
<?php
/**
* 图标生成器 · 生命周期回调
*/
if (!defined('LITELOG_ROOT')) exit('error!');
/**
* 启用插件时调用
* - 建表、写初始 option
* - 必须整段 try/catch,绝不能抛异常
*/
function callback_init() {
try {
global $DB;
$DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "my_items` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL DEFAULT '',
`created_at` INT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if (class_exists('Option')) {
foreach ([
'enabled' => '1',
'title' => '我的插件',
] as $key => $val) {
$optKey = 'my_plugin_' . $key;
if (Option::get($optKey, '') === '') {
Option::set($optKey, $val);
}
}
}
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin callback_init: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
}
/**
* 升级插件时调用(可选)
*/
function callback_up() {
callback_init();
}
/**
* 停用插件时调用
* - 保留数据,不删表
*/
function callback_rm() {
// 空操作即可。删表是用户主动卸载时才做的事。
}
/**
* 卸载插件时调用(可选,需要用户手动触发)
* 内核目前没有"卸载"入口,一般由管理员手动调用 callback_uninstall() 或直接删目录
*/
function callback_uninstall() {
global $DB;
try {
$DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "my_items`");
} catch (Throwable $e) {}
try {
$DB->delete('option', "option_name LIKE 'my_plugin_%'");
} catch (Throwable $e) {}
}
6.3 约定
callback_init()必须整段 try/catch(罪 3)- DDL 不加
COMMENT/COLLATE(罪 3) callback_rm()不删数据(停用是"临时关闭",删表是卸载的职责)callback_uninstall()先删表 → 再清 optionplugins_cache.json由系统自动重建,不要手动删
第 7 章:★ PluginRegistry 注册表
★ v2.0 修正:PluginRegistry真实存在,位于include/lib/plugin_registry.php。它在后台请求时由内核加载。
7.1 完整 API(实测)
| 方法 | 签名 | 用途 |
|---|---|---|
addAdminPage | addAdminPage(string $action, array $config) | 注册后台页面 |
hasAdminPage | hasAdminPage(string $action): bool | 是否存在该页面 |
getAdminPage | getAdminPage(string $action): ?array | 获取页面配置 |
addAdminMenu | addAdminMenu(array $menu) | 注册侧边栏菜单 |
getAdminMenus | getAdminMenus(): array | 获取所有菜单 |
addFrontendRoute | addFrontendRoute(string $regex, callable $callback) | 注册前台路由 |
matchFrontendRoute | matchFrontendRoute(string $uri): bool | 匹配前台路由 |
addAdminAsset | addAdminAsset(string $action, string $type, string $url) | 注册后台资源 |
getAdminAssets | getAdminAssets(string $action, string $type): array | 获取后台资源 |
renderAdminPage | renderAdminPage(string $action) | 渲染后台页面 |
7.2 前台路由注册
// frontend/routes.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;
try {
PluginRegistry::addFrontendRoute('icon/?', function ($matches) {
/* 主文件未加载 / 插件被禁用 → 404 */
if (!function_exists('ICG_isEnabled') || !ICG_isEnabled()) {
header('HTTP/1.0 404 Not Found');
if (class_exists('View')) View::display('404');
return;
}
/* 登录校验(可选) */
if (ICG_requireLogin() && !isLoggedIn()) {
redirect(BLOG_URL . 'admin/?action=login'); // 自带 exit
return;
}
/* 标记正在渲染本插件页面,供 Hook 判断 */
$GLOBALS['__icg_active'] = true;
/* 只 include 视图,不在这里输出 HTML */
include __DIR__ . '/../views/generator.php';
});
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] icon_generator route register: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
$regex是不带前导斜杠的正则- 捕获组通过
$matches传入,同时灌进$_GET - 匹配条件:内置伪静态未命中,且
$_GET['action']未设置
7.3 后台菜单注册
// admin/register.php
<?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
);
}
icon 支持的名字:
dashboard, file, comment, folder, settings, external, logout, plus,
edit, trash, plugin, palette, check, x, refresh
7.4 加载时机
admin/register.php 不会被内核自动加载。主文件里必须:
// 只在后台请求时加载
if (class_exists('PluginRegistry') && is_file(__DIR__ . '/admin/register.php')) {
require_once __DIR__ . '/admin/register.php';
}
PluginRegistry 类本身也只在后台加载,所以 class_exists('PluginRegistry') 是最好的守卫。
第 8 章:★ 钩子系统全览(内核实测)
8.1 Hook API
include/lib/hook.php 提供 5 个方法:
| 方法 | 说明 |
|---|---|
Hook::addAction($hook, $func, $priority = 10) | 注册回调 |
Hook::doAction($hook, ...$args) | 触发钩子 |
Hook::removeAction($hook, $func = null) | 移除回调 |
Hook::hasAction($hook) | 是否已挂载 |
Hook::captureAction($hook, ...$args) | 捕获输出并返回字符串 |
优先级:数字越小越先执行,默认 10。
重要限制:LiteLog 的 Hook 只有 action(副作用)语义,没有 filter(改值)语义。需改数据时通过引用传参:
Hook::addAction('log_save_before', function (&$data, $gid) {
$data['title'] = '[置顶] ' . $data['title'];
});
8.2 ★ 钩子清单(基于内核源码实测)
生命周期
| 钩子名 | 触发点 | 参数 |
|---|---|---|
plugin_activate | 插件启用后 | $name |
plugin_deactivate | 插件停用后 | $name |
plugin_uninstall | 插件卸载(若内核触发) | $name |
后台
| 钩子名 | 触发点 | 参数 |
|---|---|---|
admin_request | admin/index.php 登录校验后、路由分发前 | $action |
admin_head | <head> 内 | 无 |
admin_footer | </body> 前 | 无 |
admin_menu | 侧边栏「扩展」区 | $activeKey |
admin_dashboard_widgets | 概览页末尾 | 无 |
admin_log_edit_sidebar | 文章编辑页右栏 | $log |
admin_log_save_before | 文章保存前(后台) | &$data, $gid |
admin_log_save_after | 文章保存后(后台) | $gid, $data |
user_login | 登录成功后 | $user |
user_logout | 登出前 | $uid |
theme_activate | 主题切换后 | $new, $old |
option_save | 保存单键后 | $key, $value |
★ 前台模板(v2.0 修正)
| 钩子名 | 触发点 | 参数 |
|---|---|---|
template_head | 主题 <head> 内(所有前台页面) | 无 |
body_end | 主题 </body> 前(所有前台页面) | 无 |
index_head | 列表页 head 区 | 无 |
★ 关键修正:v1.2 中说的 index_head 只对列表页触发。全站范围注入 CSS/JS 应使用 template_head 和 body_end。
前台分发
| 钩子名 | 触发点 | 参数 |
|---|---|---|
dispatch_start | 分发开始(早于路由匹配) | 无 |
dispatch_end | 分发结束 | 无 |
dispatch_matched | 路由匹配后 | &$matched |
dispatch_plugin_route | 老式插件路由前 | $pluginName, $rest |
public_action_xxx | ?action=xxx 分发前 | 无 |
内容展示
| 钩子名 | 触发点 | 参数 |
|---|---|---|
log_list_query | 列表查询前 | &$cond, $page, $perPage |
log_list_result | 列表查询后 | &$logs, $page, $total |
log_list_before / log_list_head / log_list_after | 列表各阶段 | 无 |
log_item_before / log_item_meta / log_item_body_after / log_item_after | 每篇文章 | $log |
log_display_before / log_display_after | 详情渲染前后 | $log |
log_content | 正文输出 | $log |
record_display / sort_display / tag_display | 归档/分类/标签页 | — |
log_save_before / log_save_after / log_delete_before / log_delete_after | 文章生命周期 | &$data / $gid |
评论
| 钩子名 | 触发点 | 参数 |
|---|---|---|
comment_save_before | 提交前 | &$data |
comment_save_after | 插入后 | $cid, $data |
comment_post | 提交完成 | $data |
public_action_comment_post | ?action=comment_post | 无 |
扩展点
| 钩子名 | 说明 |
|---|---|
register_widgets | 注册自定义小工具 |
register_homepage_modes | 注册首页模式 |
permalink_build_log_url | 自定义文章 URL(写 $GLOBALS['__permalink_url_log']) |
permalink_build_sort_url | 自定义分类 URL(写 $GLOBALS['__permalink_url_sort']) |
8.3 钩子注册规范
- 所有
Hook::addAction(...)必须包在class_exists('Hook')+try/catch(罪 5) - 钩子命名:核心钩子名保持原样;插件自己触发供其它插件监听的,必须加插件名前缀(如
friend_links_before_save)
第 9 章:★ 数据库与模型
9.1 ★ Database 类精确签名(实测修正)
| 方法 | 参数 2 语义 | 返回值 |
|---|---|---|
query($sql, $params = []) | 有 $params → prepare;无 → prepare 空数组 | PDOStatement(可 ->rowCount()) |
fetchAll($sql, $params = []) | 同上 | array(空时 []) |
fetchOne($sql, $params = []) | 同上 | array \| false ⚠️ |
fetchColumn($sql, $params = []) | 同上 | 标量 \ |
insert($table, $data) | — | lastInsertId 字符串 ⚠️(需 (int)) |
update($table, $data, $where, $whereParams = []) | $where 不带 WHERE 关键字 | true ⚠️(不是受影响行数) |
delete($table, $where, $params = []) | 同上 | true ⚠️ |
★★ v2.0 重大修正:
fetchOne()失败返回false(不是null)——与 v1.2 相反。判断要用if ($row === false)或if (!$row)。insert()返回字符串 lastInsertId——需(int)$newId转换。update()/delete()返回true(不是行数)——无法通过返回值判断"是否真改了"。要判断是否命中,必须先SELECT或改用query()->rowCount()。
9.1.1 原子扣减要用 query()->rowCount()
因为 update() 返回 true,没法拿到行数。所以红线 5(原子扣减)的标准写法:
$stmt = $DB->query(
"UPDATE `" . DB_PREFIX . "user_profile` SET `credit`=`credit`-1 WHERE `uid`=? AND `credit`>0",
[(int)$uid]
);
if ($stmt->rowCount() === 0) {
return ['ok' => false, 'msg' => '余额不足'];
}
9.1.2 fetchOne() 的判空
$row = $DB->fetchOne("SELECT * FROM ... WHERE gid=?", [(int)$gid]);
if ($row === false) { /* 不存在 */ } // 或 if (!$row)
9.1.3 insert() 返回值
$newId = (int)$DB->insert('my_items', [...]); // ← 一定要 (int)
9.2 核心表清单(★ v2.0 修正)
{DB_PREFIX}user(用户主表)
| 列 | 类型 | 说明 |
|---|---|---|
uid | INT PK | 注意是 uid 不是 id |
username | VARCHAR | 登录名 |
nickname | VARCHAR | 昵称(显示优先 nickname ?: username) |
email | VARCHAR | 邮箱 |
password | VARCHAR | 密码 hash |
role | VARCHAR | 'admin' 为管理员 |
{DB_PREFIX}blog(文章表)
| 列 | 类型 | 说明 |
|---|---|---|
gid | INT PK | 注意是 gid 不是 id |
title | VARCHAR | 标题 |
content | LONGTEXT | 正文 |
content_md | LONGTEXT | Markdown 源 |
alias | VARCHAR | URL 别名 |
sortid | INT | 分类 ID(不是 sort_id) |
author | INT | 作者 UID |
date | INT UNSIGNED | 发布时间戳 |
views | INT UNSIGNED | 浏览量 |
comnum | INT | 评论数 |
hide | VARCHAR(1) | 'y'=隐藏,'n'=公开 ⚠️(不是 1/0) |
type | VARCHAR | 'blog' / 'page' |
top | INT | 置顶 |
cover | VARCHAR | 封面图 URL |
excerpt | TEXT | 摘要 |
{DB_PREFIX}comment(评论表)
| 列 | 类型 | 说明 |
|---|---|---|
cid | INT PK | 评论 ID |
gid | INT | 文章 ID |
pid | INT | 父评论 ID(0 = 顶级) |
poster | VARCHAR | 昵称 |
email | VARCHAR | 邮箱 |
url | VARCHAR | 网址 |
comment | TEXT | 内容 |
ip | VARCHAR | IP |
agent | VARCHAR | UA |
date | INT UNSIGNED | 时间戳 |
hide | VARCHAR(1) | 'y'=待审,'n'=通过 |
{DB_PREFIX}sort(分类表)
| 列 | 类型 | 说明 |
|---|---|---|
sid | INT PK | 不是 id |
sortname | VARCHAR | 分类名 |
alias | VARCHAR | URL 别名 |
pid | INT | 父分类 |
description | TEXT | 描述 |
{DB_PREFIX}tag / {DB_PREFIX}tag_blog
★ v2.0 修正:关联表是 tag_blog(不是 tag_relation)。
| 表 | 主键 | 关键列 |
|---|---|---|
tag | tid | tagname |
tag_blog | — | gid / tid |
{DB_PREFIX}option(配置表)
| 列 | 类型 | 说明 |
|---|---|---|
id | INT PK | — |
option_name | VARCHAR UNIQUE | 键名 |
option_value | TEXT | 值(永远是字符串) |
autoload | VARCHAR(1) | 'y' / 'n' |
★ 实测常用 option 键名:
| 键名 | 值 | 说明 |
|---|---|---|
site_title | 字符串 | 站点名(不是 site_name) |
site_description | 字符串 | 站点描述 |
site_key | 字符串 | SEO 关键词 |
current_template | 字符串 | 当前主题目录名 |
url_rewrite | 'y' / 'n' | 伪静态开关 |
index_lognum | 数字字符串 | 首页文章数 |
comment_need_check | 'y' / 'n' | 评论审核 |
active_plugins | CSV 字符串 | 已启用插件名列表 |
★ Option::get() 返回永远是 string(不是 int / bool / array)。判断用 === '1' / === 'y'。
9.3 $DB 使用示例
global $DB;
// 1) 查一条(失败返回 false)
$row = $DB->fetchOne(
"SELECT * FROM `" . DB_PREFIX . "user` WHERE uid = ?",
[(int)$uid]
);
if ($row === false) { /* 不存在 */ }
// 2) 查多条
$rows = $DB->fetchAll(
"SELECT * FROM `" . DB_PREFIX . "blog` WHERE `sortid` = ? ORDER BY `date` DESC LIMIT 10",
[(int)$sid]
);
// 3) 标量
$count = (int)$DB->fetchColumn(
"SELECT COUNT(*) FROM `" . DB_PREFIX . "user` WHERE `status` = 1"
);
// 4) 插入
$newId = (int)$DB->insert('my_items', [
'name' => 'test',
'created_at' => time(),
]);
// 5) 更新(无行数返回)
$DB->update('my_items', ['name' => 'new'], 'id = ?', [(int)$id]);
// 6) 删除(无行数返回)
$DB->delete('my_items', 'id = ?', [(int)$id]);
// 7) 要判断"是否命中",用 query + rowCount
$stmt = $DB->query("UPDATE `" . DB_PREFIX . "user_profile` SET credit = credit - 1 WHERE uid = ? AND credit > 0", [(int)$uid]);
if ($stmt->rowCount() === 0) { /* 未命中 */ }
// 8) IN 查询
$ids = array_map('intval', $ids);
if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?'));
$rows = $DB->fetchAll("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid` IN ($in)", $ids);
}
// 9) 事务
$pdo = $DB->pdo();
$pdo->beginTransaction();
try {
$DB->update('user_profile', ['credit' => 100], 'uid = ?', [(int)$uid]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
9.4 核心模型
| 模型 | 主要方法 |
|---|---|
LogModel | query() / getOne() / getByAlias() / latest() / prev() / next() / add() / update() / delete() / addView() / count() |
SortModel | all() / getOne() / getByAlias() / add() / update() / delete() / countInSort() |
TagModel | all() / getByName() / getByGid() / getGidsByTag() / attach() / detach() |
CommentModel | getOne() / byLog() / byLogPaginated() / buildTree() / latest() / add() / delete() / countByLog() / refreshComnum() / hasRecentDuplicate() |
UserModel | getOne() / getByUsername() / login() / update() |
LinkModel | all() |
优先使用模型方法。
9.5 Option 配置存储
// 少量配置
Option::set('my_plugin_enabled', '1');
$v = Option::get('my_plugin_enabled', ''); // ★ 第二参数永远 ''
// 复杂结构 → JSON
Option::set('my_plugin_config', json_encode(['a' => 1], JSON_UNESCAPED_UNICODE));
$cfg = json_decode(Option::get('my_plugin_config', '{}'), true);
键名前缀强制 {插件名}_。
第 10 章:★ 视图与模板
★ v2.0 完全重写:v1.2 只说了"后台视图不要 include layout",但没说前台视图该怎么做。这一章补齐。
10.1 ★ 前台视图必须 include 主题 header/footer
这是 v2.0 最重要的修正。
错误做法(导致标题显示为站点名的元凶):
<!-- ❌ views/generator.php -->
<!DOCTYPE html>
<html>
<head>
<title>图标生成器 - 站点名</title> <!-- ← 主题 header.php 已输出过 <title>,这个被浏览器忽略 -->
</head>
<body>
... 内容 ...
</body>
</html>
正确做法:
<!-- ✅ views/generator.php -->
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
// 设置页面标题(由主题 header.php 输出到 <title>)
$page_title = '图标生成器 - ' . Option::get('site_title', '');
// include 主题 header(输出 <!DOCTYPE html><html><head>...</head><body>...)
include View::resolve('header');
?>
<!-- 只输出 body 内部内容 -->
<div class="my-container">
... 页面内容 ...
</div>
<?php include View::resolve('footer'); ?>
原理:
View::resolve('header')会解析到当前主题的header.php- 主题的
header.php已经输出了完整的<!DOCTYPE html><html><head><title>...</title>... - 我们只需设
$page_title变量,主题会读取它渲染<title> - 视图只需输出
<body>内部的内容
10.2 视图位置
| 类型 | 位置 |
|---|---|
| 前台视图 | content/plugins/{插件名}/views/*.php |
| 后台视图 | content/plugins/{插件名}/admin/pages/*.php |
10.3 视图顶部守卫模板
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
/* ★ 前置守卫:主文件未加载则优雅退场 */
if (!function_exists('ICG_isEnabled')) {
echo '<div class="error">插件未正确加载,请检查插件目录与激活状态。</div>';
return;
}
/* ★ 站点名探测(可选,用于标题) */
$__site = Option::get('site_title', '');
$page_title = '图标生成器' . ($__site !== '' ? ' - ' . $__site : '');
include View::resolve('header');
?>
<!-- 页面内容 -->
<?php include View::resolve('footer'); ?>
10.4 后台视图的特殊约定
后台视图由 PluginRegistry::addAdminPage 的 callback 调用,内核已包好后台骨架(admin/views/layout.php)。所以后台视图不要 include 主题 header/footer:
<?php
// admin/pages/settings.php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!function_exists('ICG_isEnabled')) { echo '插件未加载'; return; }
?>
<h2>设置</h2>
<div class="card">
... 后台页面内容 ...
</div>
10.5 输出转义
<h1><?= h($log['title']) ?></h1>
<a href="<?= h($url) ?>"><?= h($name) ?></a>
10.6 后台可用的核心 CSS 类
| 类名 | 用途 |
|---|---|
.card / .card-title | 卡片 |
.btn / .btn-outline / .btn-danger / .btn-sm | 按钮 |
.table-wrap / .table | 表格 |
.form / .field | 表单 |
.tag / .tag-published / .tag-hidden | 标签 |
.empty / .error / .pagination | 其它 |
10.7 常用辅助函数
| 函数 | 说明 |
|---|---|
h($s) | HTML 转义 |
redirect($url) | 302 跳转 + exit(已自带 exit) |
isLoggedIn() | 是否登录 |
checkLogin() | 强制登录 |
dateFmt($ts) / datetimeFmt($ts) | 日期格式化 |
stripTags($s, $len) / getExcerpt($html, $len) | 文本处理 |
makeAlias($str) | URL 别名 |
pagination($page, $total, $perPage) | 分页 HTML |
getPageNumber() / pageUrl($page) | 分页参数 |
10.8 URL 生成
Url::log($gid, $alias); // 文章链接
Url::sort($sid, $alias); // 分类链接
Url::tag($name); // 标签链接
Url::author($uid); // 作者链接
Url::record('2026-10'); // 归档链接
禁止硬编码 '/post/1.html'、'/admin/'。
第 11 章:★ 资源加载
★ v2.0 完全重写:v1.2 建议"视图内直接输出<link>/<script>",这在前台视图里会导致资源出现在<body>中间。正确做法是走 Hook 注入。
11.1 目录
- 插件静态资源:
content/plugins/{插件名}/assets/ - 后台专属资源:
content/plugins/{插件名}/admin/assets/
11.2 前台资源:通过 Hook 注入
// 主文件 icon_generator.php
if (class_exists('Hook')) {
try {
/* 注入 CSS 到主题 <head> */
Hook::addAction('template_head', function () {
if (empty($GLOBALS['__icg_active'])) return; // 只在插件页面注入
$ver = (int)(@filemtime(__DIR__ . '/assets/style.css') ?: 1);
echo '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">' . "\n";
echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/icon_generator/assets/style.css?v=' . $ver . '">' . "\n";
});
/* 注入 JS 到 </body> 前 */
Hook::addAction('body_end', function () {
if (empty($GLOBALS['__icg_active'])) return;
$ver = (int)(@filemtime(__DIR__ . '/assets/script.js') ?: 1);
echo '<script src="' . BLOG_URL . 'content/plugins/icon_generator/assets/script.js?v=' . $ver . '"></script>' . "\n";
});
} catch (Throwable $e) { /* 写日志 */ }
}
视图里只需要:
// frontend/routes.php 回调里
$GLOBALS['__icg_active'] = true; // 告诉 Hook 我们正在渲染本插件页面
include __DIR__ . '/../views/generator.php';
11.3 后台资源
Hook::addAction('admin_head', function () {
if (empty($GLOBALS['__icg_admin'])) return;
echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.css">' . "\n";
});
Hook::addAction('admin_footer', function () {
if (empty($GLOBALS['__icg_admin'])) return;
echo '<script src="' . BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.js"></script>' . "\n";
});
11.4 版本号防缓存
$ver = @filemtime(CONTENT_PATH . 'plugins/my_plugin/assets/style.css') ?: 1;
echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/assets/style.css?v=' . (int)$ver . '">';
11.5 零内联原则
- CSS 收敛到
assets/.css,JS 收敛到assets/.js - 避免在内联
<script>里塞大段逻辑 - ★ v2.0 提示:如果视图不能输出
<head>(因为它被 include 到主题骨架里),那 CSS/JS 必须走 Hook
11.6 数据目录保护
content/plugins/{插件名}/data/ 必须放 .htaccess 禁止 Web 访问:
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
第 12 章:小工具与首页模式扩展
12.1 自定义小工具
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>...</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="键名" 属性。
12.2 自定义首页模式
class HomepageMode_MyMode extends HomepageMode {
public $id = 'my_mode';
public $name = '我的模式';
public $description = '显示自定义首页内容';
public $theme = 'purple';
public $icon = '<svg>...</svg>';
public $priority = 50;
public function isAvailable() { return true; }
public function render($ctx = []) {
echo '<div class="my-mode">...</div>';
}
}
Hook::addAction('register_homepage_modes', function () {
HomepageModeRegistry::register(new HomepageMode_MyMode());
});
第 13 章:加载时机 / exit 边界 / 命名约定
13.1 请求生命周期(前台)
请求进入 index.php
↓
init.php 执行
├─ 定义常量:LITELOG_ROOT / CONTENT_PATH / ADMIN_PATH / INCLUDE_PATH / BLOG_URL / DB_PREFIX
├─ 加载核心 lib:Database($DB) / Option / Hook / 模型类 / helper 函数
├─ 建立 PDO 连接
└─ loadActivePlugins()
└─ 只 require 每个启用插件的 {插件名}.php
↓
Dispatcher::dispatch()
├─ Hook::doAction('dispatch_start')
├─ 检查 $_GET['__plugin_path'] → dispatch_plugin_route
├─ 标准 Rewrite URL 解析
├─ PluginRegistry::matchFrontendRoute() → 匹配到则执行回调
├─ Hook::doAction('public_action_{action}')
└─ 渲染
★ v2.0 提示:PluginRegistry::matchFrontendRoute() 在内核 Dispatcher 里被主动调用(include/lib/dispatcher.php)。所以只要 PluginRegistry::addFrontendRoute 注册成功,路由就会自动匹配。
13.2 各文件已可用符号表
| 文件 | 触发时机 | 已可用符号 |
|---|---|---|
| 主文件 | init 后、路由前 | 常量、$DB、Option、Hook、模型类、h()、redirect()、isLoggedIn()、$_SESSION['uid'] |
frontend/routes.php | 主文件 require 时 | 上述全部 + PluginRegistry(仅后台请求时可用) |
admin/register.php | 后台请求时(主文件 require) | 上述全部 + PluginRegistry、adminIcon() |
{插件名}_callback.php | 后台启用/停用时 | LITELOG_ROOT、CONTENT_PATH、DB_PREFIX、$DB、Option |
| 前台视图 | 由路由回调 include | 全局全部可用 |
| 后台视图 | 由 callback include | 全局全部可用 |
注意:
- ⚠️
BLOG_URL在 callback 里不要用(可能 CLI 触发) - ⚠️
Hook在 callback 里不要用(用plugin_activate等核心钩子)
13.3 exit / header / die 边界表
| 钩子 / 场景 | 能否 exit | 能否 header | 说明 |
|---|---|---|---|
| 主文件顶层 | ❌ | ❌ | 只能定义符号 |
frontend/routes.php 回调入口 | ✅ | ✅ | 路由端点标准位置 |
{插件名}_callback.php | ❌ | ❌ | 只做 DB 操作 |
渲染类钩子(template_head / body_end / admin_head / admin_footer / log_item_meta / log_display_after) | ❌ 红线 3 | ❌ | 布局已开始输出,中断毁整页 |
admin_request | ⚠️ 谨慎 | ⚠️ 谨慎 | — |
dispatch_start | ✅ | ✅ | 尚未输出 |
public_action_xxx | ✅ 必须 | ✅ 必须 | AJAX / 表单端点标准位置 |
dispatch_plugin_route | ✅ | ✅ | 伪静态路由处理点 |
comment_save_before / comment_save_after | ❌ | ❌ | 数据层钩子 |
plugin_activate / plugin_deactivate | ❌ | ❌ | 生命周期事件 |
13.4 命名约定:全局 vs 命名空间
LiteLog 插件不使用 PHP 命名空间。 所有类、函数、常量都是全局的。
| 符号 | 命名 | 示例 |
|---|---|---|
| 函数 | {前缀}_{CamelCase} | ICG_isEnabled() |
| 常量 | {前缀}_UPPER | ICG_VER |
| 类 | {Prefix}Camel 或 {Prefix}_Camel | Widget_IconGen |
| 表名 | {DB_PREFIX}{插件名}_{表名} | lg_icon_generator_items |
| Option 键 | {插件名}_{键名} | icon_generator_enabled |
前缀选取:
- 短且唯一:3-6 个字母,全大写(函数/常量)
- 核心已占用
Forum_*/UserGroup_*/Uc_*/Widget_*/HomepageMode_*,不能重复
13.5 路径常量与斜杠约定
| 常量 | 值示例 | 结尾斜杠 |
|---|---|---|
LITELOG_ROOT | /var/www/litelog/ | ✅ 带 |
CONTENT_PATH | /var/www/litelog/content/ | ✅ 带 |
ADMIN_PATH | /var/www/litelog/admin/ | ✅ 带 |
INCLUDE_PATH | /var/www/litelog/include/ | ✅ 带 |
BLOG_URL | https://example.com/ | ✅ 带 |
DB_PREFIX | lg_ | ❌ 无 |
// ✅ 正确:常量结尾已带 /,不加前导斜杠
$file = CONTENT_PATH . 'plugins/my_plugin/data.json';
$url = BLOG_URL . 'content/plugins/my_plugin/assets/admin.css';
$link = BLOG_URL . 'admin/?action=my_plugin';
// ❌ 错误:多一个斜杠
$url = BLOG_URL . '/content/plugins/...';
// 表名拼接:DB_PREFIX 无下划线,需自己加
$table = DB_PREFIX . 'my_plugin_items'; // lg_my_plugin_items
第 14 章:★ API 参考(实测)
14.1 Database 类
见 9.1。
14.2 核心表清单
见 9.2。
14.3 核心 helper 函数完整清单
字符串 & 输出
| 函数 | 签名 | 说明 |
|---|---|---|
h($s) | h(mixed): string | HTML 转义 |
stripTags($s, $len = 0) | stripTags(string, int): string | 去标签 |
getExcerpt($html, $len = 200) | getExcerpt(string, int): string | 提取摘要 |
makeAlias($str) | makeAlias(string): string | URL 别名 |
时间
| 函数 | 说明 |
|---|---|
dateFmt($ts) | Y-m-d |
datetimeFmt($ts) | Y-m-d H:i |
跳转 & 会话
| 函数 | 说明 |
|---|---|
redirect($url) | 302 跳转 + exit(已自带 exit) |
isLoggedIn() | 是否登录 |
checkLogin() | 强制登录 |
分页 & 请求
| 函数 | 说明 |
|---|---|
pagination($page, $total, $perPage) | 分页 HTML |
getPageNumber() | 当前页码 |
pageUrl($page) | 保留 GET 参数的分页链接 |
Option 类
Option::get($key, $default = ''); // ★ 第二参数永远用 '',不能传 null
Option::set($key, $value);
Option::all();
View 类
View::resolve($name); // 返回主题模板文件路径
View::display($name, $data = []); // 渲染并输出(extract $data)
Url 类
Url::log($gid, $alias); // 文章链接
Url::sort($sid, $alias); // 分类链接
Url::tag($name); // 标签链接
Url::author($uid); // 作者链接
Url::record($date); // 归档链接
Hook 类
Hook::addAction($hook, $func, $priority = 10);
Hook::doAction($hook, ...$args);
Hook::removeAction($hook, $func = null);
Hook::hasAction($hook);
Hook::captureAction($hook, ...$args);
PluginRegistry 类
见 第 7 章。
核心模型
见 9.4。
不可用的函数
- ❌
input()/request()/post()—— 直接读$_GET/$_POST - ❌
json_response()/api_response()—— 自己header+json_encode+exit - ❌
auth_user()/current_user()—— 用$_SESSION['uid'] - ❌
db()—— 用全局$DB - ❌
config()—— 用Option::get()
14.4 AJAX 端点八条铁律
- 第一行
header('Content-Type: application/json; charset=utf-8') - 登录校验(红线 8)
- 方法校验(写操作必须拒绝
GET) - 参数强转 + 范围校验
- DB 操作整段
try/catch - 事务内先
inTransaction()再rollBack() - 异常不逃逸(catch 里必须
echo json_encode(...)再exit) exit收尾
标准 AJAX 端点模板
if (!function_exists('MP_handleAjax')) {
function MP_handleAjax() {
header('Content-Type: application/json; charset=utf-8');
if (empty($_SESSION['uid'])) {
echo json_encode(['ok' => false, 'msg' => '请先登录'], JSON_UNESCAPED_UNICODE);
exit;
}
$uid = (int)$_SESSION['uid'];
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'msg' => '方法不允许'], JSON_UNESCAPED_UNICODE);
exit;
}
$itemId = (int)($_POST['item_id'] ?? 0);
$points = (int)($_POST['points'] ?? 0);
if ($itemId <= 0 || $points <= 0) {
echo json_encode(['ok' => false, 'msg' => '参数错误'], JSON_UNESCAPED_UNICODE);
exit;
}
global $DB;
$pdo = null;
try {
$row = $DB->fetchOne(
"SELECT id, owner_id FROM `" . DB_PREFIX . "my_items` WHERE id = ?",
[$itemId]
);
if ($row === false) {
echo json_encode(['ok' => false, 'msg' => '目标不存在'], JSON_UNESCAPED_UNICODE);
exit;
}
$pdo = $DB->pdo();
$pdo->beginTransaction();
// 原子扣减(红线 5)
$stmt = $DB->query(
"UPDATE `" . DB_PREFIX . "user_profile`
SET credit = credit - ?
WHERE uid = ? AND credit >= ?",
[$points, $uid, $points]
);
if ($stmt->rowCount() === 0) {
$pdo->rollBack();
echo json_encode(['ok' => false, 'msg' => '余额不足'], JSON_UNESCAPED_UNICODE);
exit;
}
$DB->query(
"UPDATE `" . DB_PREFIX . "user_profile` SET credit = credit + ? WHERE uid = ?",
[$points, (int)$row['owner_id']]
);
$pdo->commit();
echo json_encode(['ok' => true, 'msg' => '成功'], JSON_UNESCAPED_UNICODE);
exit;
} catch (Throwable $e) {
try {
if ($pdo && $pdo->inTransaction()) $pdo->rollBack();
} catch (Throwable $ignored) {}
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] MP_handleAjax: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
echo json_encode(['ok' => false, 'msg' => '服务器错误'], JSON_UNESCAPED_UNICODE);
exit;
}
}
}
第 15 章:常见坑清单
15.1 安全
- 路径穿越 —— 文件名一律过
basename() - SQL 注入 —— 必须占位符绑定或
(int)强转 - XSS —— 输出必须过
h() - IDOR —— 按 ID 操作必须校验资源存在
- 上传 —— 扩展名白名单 + 真实图片校验 + 随机文件名
- 开放重定向 ——
redirect($url)只允许本站 URL
15.2 部署兼容
- 不硬编码路径 —— URL 拼
BLOG_URL,文件路径拼CONTENT_PATH/DIR - 子目录部署 —— 假设站点可能在
https://example.com/blog/ - 大小写敏感文件系统 —— 目录名小写下划线
15.3 结构规范
- 一目录一插件 —— 目录名小写下划线
- 不留临时文件 ——
.bak/.tmp/.old一律清理 - 不改核心文件 —— 走
PluginRegistry+Hook - 插件 option 加插件名前缀
- 生命周期文件不输出内容
- UTF-8 无 BOM,LF 行尾,
php -l校验 - ★ 主文件只做四件事:常量、函数、Hook 注册、require 子文件
- ★ 前台视图必须 include 主题 header/footer
- ★ CSS/JS 走 Hook 注入
15.4 性能
- 避免 N+1 —— 批量 ID 一次性
IN (...) - 静态缓存 ——
static变量缓存同请求查询 - 钩子轻量化 —— 高频钩子禁止重查询
- 避免在高频钩子里做同步 IO
15.5 兼容性
$DB是全局 —— 函数里用必须global $DB;$data引用参数 ——log_save_before/comment_save_before必须function (&$data, ...)redirect()已自带 exit —— 自定义跳转要自己加$_SESSION['uid']是登录标识- ★
fetchOne()失败返回false(不是null) - ★
insert()返回字符串 lastInsertId —— 必须(int) - ★
update()/delete()返回true(不是行数)
15.6 卸载自检
- 插件自建表已删(
DROP TABLE IF EXISTS) - option 表里本插件键已清(
DELETE ... WHERE option_name LIKE 'xxx_%') - 用户表中本插件写入的字段已清(如有)
- 插件
data/目录敏感文件已清(不删插件目录本身)
第 16 章:★ 调试方法与验证流程
★ v2.0 全新章节:从本次开发经验中提炼。
16.1 遇到"行为不符预期"时的第一反应
不要猜。要读。
| 症状 | 第一步 |
|---|---|
| 后台 500 | 打开 display_errors,看报错行号;查 content/backup/plugin-error.log |
| 前台访问白屏 | F12 打开 Console,看是否有 JS 报错;看 Network 里 HTML 响应内容 |
| 路由不生效(访问 404) | 读 include/lib/dispatcher.php,看路由匹配逻辑 |
| 标题不对 | 读主题的 header.php,看它读取哪个变量 |
| CSS/JS 没加载 | F12 Network 面板,看请求是否发出、状态码是什么 |
| 数据没保存 | 读 include/lib/mysql.php,看 update() / insert() 返回值含义 |
16.2 用 error_log 打点
在主文件或视图里临时加:
@file_put_contents(
CONTENT_PATH . 'backup/debug.log',
'[' . date('Y-m-d H:i:s') . '] ' . var_export($someVar, true) . "\n",
FILE_APPEND | LOCK_EX
);
刷新页面,看 content/backup/debug.log 里的输出。
16.3 用 var_dump 短路调试
var_dump($page_title); // 看变量到底有没有值
var_dump(get_defined_vars()); // 看视图里所有可用变量
exit; // 临时中断,避免页面渲染
16.4 用 F12 排查前端
- Console:看 JS 报错
- Network:看 CSS/JS/API 请求是否 200
- Elements:检查最终 DOM,确认
<head>里是否有我们的<link> document.title:直接看最终标题变量
16.5 阅读内核源码的优先顺序
遇到问题时,按这个顺序读源码:
index.php/admin/index.php—— 入口init.php—— 常量与初始化include/lib/dispatcher.php—— 前台路由include/lib/hook.php—— 钩子实现include/lib/function.common.php—— 插件加载include/lib/plugin_registry.php—— 注册表content/templates/{当前主题}/header.php—— 主题骨架include/model/*.php—— 模型
16.6 "参考实例对齐法"
有可工作参考时,先做结构对齐,再谈业务。
对齐清单:
- [ ] 目录结构是否一致
- [ ] 主文件是否只做四件事(常量 / 函数 / Hook / require)
- [ ] 视图是否
include View::resolve('header') - [ ] CSS/JS 是否走 Hook 注入
- [ ] 生命周期文件命名是否
{插件名}_callback.php - [ ] 路由注册是否走
PluginRegistry::addFrontendRoute
16.7 交付前的三步验证
php -l每个.php文件- 启用 → 访问 → 停用 → 再启用 → 卸载 全流程走一遍
- F12 检查:Console 无报错、Network 全 200、Elements 结构正确
第 17 章:★ 完整最小骨架(可直接抄写)
AI 直接抄这份骨架,改文件名和业务逻辑即可。
17.1 目录结构
content/plugins/my_plugin/
├── my_plugin.php ← 主文件
├── my_plugin_callback.php ← 生命周期回调
├── frontend/
│ └── routes.php ← 前台路由注册
├── views/
│ └── main.php ← 前台视图
└── assets/
├── style.css
└── script.js
17.2 my_plugin.php
<?php
/**
* Plugin Name: 我的插件
* Description: 一句话描述功能
* Version: 1.0.0
* Author: 你的名字
* Author URI: https://example.com
*/
if (!defined('LITELOG_ROOT')) exit('error!');
/* ---------- 常量守卫(罪 1) ---------- */
if (!defined('MP_VER')) define('MP_VER', '1.0.0');
/* ---------- 配置读取 ---------- */
if (!function_exists('MP_conf')) {
function MP_conf($key, $default = '') {
if (!class_exists('Option')) return $default;
try {
$v = Option::get('my_plugin_' . $key, ''); // ★ 第二参数用 ''
} catch (Throwable $e) {
return $default;
}
return $v !== '' ? $v : $default;
}
}
if (!function_exists('MP_isEnabled')) {
function MP_isEnabled() {
return MP_conf('enabled', '1') === '1';
}
}
/* ---------- Hook 注册(罪 5) ---------- */
if (class_exists('Hook')) {
try {
/* 注入 CSS 到主题 <head> */
Hook::addAction('template_head', function () {
if (empty($GLOBALS['__mp_active'])) return;
$ver = (int)(@filemtime(__DIR__ . '/assets/style.css') ?: 1);
echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/assets/style.css?v=' . $ver . '">' . "\n";
});
/* 注入 JS 到 </body> 前 */
Hook::addAction('body_end', function () {
if (empty($GLOBALS['__mp_active'])) return;
$ver = (int)(@filemtime(__DIR__ . '/assets/script.js') ?: 1);
echo '<script src="' . BLOG_URL . 'content/plugins/my_plugin/assets/script.js?v=' . $ver . '"></script>' . "\n";
});
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin hook register: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
}
/* ---------- 加载路由文件(罪 7) ---------- */
if (class_exists('PluginRegistry')) {
try {
$__routes = __DIR__ . '/frontend/routes.php';
if (is_file($__routes)) require_once $__routes;
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin require routes: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
}
17.3 my_plugin_callback.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
function callback_init() {
try {
global $DB;
$DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "my_plugin_items` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL DEFAULT '',
`created_at` INT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if (class_exists('Option')) {
foreach ([
'enabled' => '1',
'title' => '我的插件',
] as $key => $val) {
$optKey = 'my_plugin_' . $key;
if (Option::get($optKey, '') === '') {
Option::set($optKey, $val);
}
}
}
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin callback_init: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
}
function callback_up() { callback_init(); }
function callback_rm() { /* 保留数据 */ }
function callback_uninstall() {
global $DB;
try { $DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "my_plugin_items`"); } catch (Throwable $e) {}
try { $DB->delete('option', "option_name LIKE 'my_plugin_%'"); } catch (Throwable $e) {}
}
17.4 frontend/routes.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;
try {
PluginRegistry::addFrontendRoute('my_plugin/?', function ($matches) {
if (!function_exists('MP_isEnabled') || !MP_isEnabled()) {
header('HTTP/1.0 404 Not Found');
if (class_exists('View')) View::display('404');
return;
}
$GLOBALS['__mp_active'] = true;
include __DIR__ . '/../views/main.php';
});
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin route register: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
17.5 views/main.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
/* 前置守卫(罪 4) */
if (!function_exists('MP_isEnabled')) {
echo '<div style="max-width:600px;margin:80px auto;padding:20px;text-align:center;'
. 'border:1px solid #fca5a5;border-radius:8px;color:#b91c1c;background:#fef2f2;font-family:sans-serif">'
. '插件未正确加载,请检查插件目录与激活状态。</div>';
return;
}
/* 设置页面标题(由主题 header.php 输出到 <title>) */
$__site = Option::get('site_title', '');
$page_title = '我的插件' . ($__site !== '' ? ' - ' . $__site : '');
$title = $page_title; // 双写,兼容不同主题
include View::resolve('header');
?>
<div class="mp-container">
<h1>我的插件</h1>
<p>Hello World</p>
</div>
<?php include View::resolve('footer'); ?>
17.6 assets/style.css / assets/script.js
/* my_plugin styles */
.mp-container { max-width: 960px; margin: 0 auto; padding: 20px; }
/* my_plugin scripts */
(function () {
'use strict';
// 你的逻辑
})();
空文件也必须存在(否则 filemtime() 会 warning)。
第 18 章:交付前自检清单
结构
- [ ] 目录名 = 主文件名(小写下划线)
- [ ] 有
{插件名}.php+{插件名}_callback.php - [ ] 有
frontend/routes.php(如需要路由) - [ ] 有
views/目录(如需要视图) - [ ] 有
assets/目录(哪怕空文件)
安全
- [ ] 所有 SQL 用占位符(规则 2)
- [ ] 所有输出过
h()(规则 3) - [ ] 无文件操作,或文件操作过
basename()(规则 1) - [ ] AJAX 写操作有登录校验(红线 8)
- [ ] 原子扣减用
query()->rowCount()(红线 5) - [ ] 无
unserialize/eval(红线 1) - [ ]
IN (...)用array_map('intval')+ 占位符(红线 6) - [ ] 未覆盖 PDO 属性(红线 7)
⭐ 500 免疫
- [ ] 主文件每个
define()有if (!defined(...))(罪 1) - [ ] 主文件每个
function xxx()有if (!function_exists(...))(罪 1) - [ ] 所有
Option::get()第二参数是'',无null(罪 2) - [ ]
callback_init()整段try/catch,DDL 无COMMENT/COLLATE(罪 3) - [ ] 视图顶部有
function_exists()守卫(罪 4) - [ ] 所有
Hook::addAction()包在class_exists('Hook')+try/catch(罪 5) - [ ]
PluginRegistry调用前有class_exists()守卫(罪 6) - [ ]
frontend/routes.php在主文件里被require_once(罪 7)
⭐ 视图与资源
- [ ] 前台视图
include View::resolve('header')和footer(10.1) - [ ] 前台视图设置
$page_title(和$title) - [ ] 前台视图不自建
<!DOCTYPE html><html><head> - [ ] CSS/JS 通过 Hook 注入,不是视图里直接 link(第 11 章)
- [ ] 资源 URL 拼
BLOG_URL,带?v=filemtime
API 使用
- [ ]
$DB->fetchOne()判false(9.1) - [ ]
$DB->insert()返回值(int)转换(9.1) - [ ] 需要行数用
query()->rowCount(),不用update()/delete()返回值(9.1) - [ ] 用户名用
nickname ?: username - [ ] URL 拼接不重复加斜杠
- [ ] 函数/常量/表名有插件前缀
- [ ] AJAX 端点八条铁律全过(14.4)
验证
- [ ]
php -l每个.php文件都过 - [ ] 启用 → 访问 → 停用 → 再启用 → 卸载 全流程无异常
- [ ] 浏览器标签页标题正确(如「图标生成器 - 站点名」)
- [ ] F12 Console 无报错,Network 全 200
- [ ] 卸载后表删除、option 清空
第 19 章:★ 开发经验教训(实测总结)
★ v2.0 全新章节:从 Icon Generator 插件的反复修复经历中提炼。
19.1 十条铁律
| # | 教训 |
|---|---|
| 1 | 规范是地图,内核是地形。地图和地形冲突时,信地形 |
| 2 | 不确定的机制,先用 error_log / var_dump / 读源码验证,禁止靠猜 |
| 3 | 有可工作参考实例时,先做"逐行结构对齐",再谈业务 |
| 4 | 导出/文档没看到某物 ≠ 某物不存在;先问,再断言 |
| 5 | 每条规范都有场景前提,问清上下文再套用 |
| 6 | 少说"一定";多说"请验证" / "如未生效,查这里" |
| 7 | "多键兜底""兼容写法"往往是懒惰的遮羞布;读源码胜过写兜底 |
| 8 | 视图不能自建 HTML 骨架(前台视图必须 include 主题 header/footer) |
| 9 | CSS/JS 走 Hook 注入(template_head / body_end),不是视图里直接 link |
| 10 | 内核只自动加载主文件;frontend/routes.php / admin/register.php 需主文件主动 require |
19.2 常见错误推理链
错误推理链 A:路由不生效 → "可能 PluginRegistry 不存在" → 改用 Hook → 引入新问题
正确推理链:路由不生效 → 读 dispatcher.php → 发现路由匹配在核心 → 检查 PluginRegistry::matchFrontendRoute 是否被调用 → 检查自己的注册代码 → 修复
错误推理链 B:标题不显示 → "可能变量名不对" → 猜 $title、$pageTitle、$GLOBALS → 猜 5 次
正确推理链:标题不显示 → 读主题 header.php 前 30 行 → 看它读哪个变量 → 设那个变量
19.3 开发流程建议
- 第一步:读参考实例(找同类型已工作插件,逐行对齐目录/主文件/视图/资源结构)
- 第二步:读内核关键文件(
init.php/dispatcher.php/hook.php/ 主题header.php) - 第三步:搭骨架(用第 17 章模板,先用 Hello World 验证路由通不通)
- 第四步:填业务(一次加一个功能,每加一个立即测试)
- 第五步:自检(用第 18 章清单逐条打钩)
- 第六步:交付(附上完整文件清单 + 使用说明)
19.4 反模式清单
不要做这些事:
- ❌ 在视图里自建
<!DOCTYPE html><html><head><title> - ❌ 在视图里直接写
<link>/<script>(应该走 Hook) - ❌ 视图里直接调用插件函数而不做
function_exists守卫 - ❌ 在主文件顶层
echo/header()/ 耗时 IO - ❌ 用
activate.php/deactivate.php/uninstall.php(不是内核约定) - ❌ 在
update()/delete()返回true时判断> 0 - ❌ 忘记
(int)转换insert()返回值 - ❌ 忘记主文件
require_once frontend/routes.php - ❌ 猜变量名 / 猜 option 键名 / 猜函数名
- ❌ 用"应该能行""这次一定"来交付
第 20 章:给 AI 的指令模板
20.1 从零开发新插件
请按 LiteLog 插件开发规范 v2.0 开发一个 XXX 插件:
【附件:LiteLog 插件开发规范 v2.0 完整版.md】
需求:
- 功能:……
- 前台入口:URL ……,action=……
- 后台入口(可选):菜单名「……」,action=……
- 数据表:……
严格遵守本文件所有条款,特别是:
- 第 1 章「500 错误免疫」的 7 条口诀(最重要)
- 第 4 章「自动加载真相」(主文件必须 require 子文件)
- 第 10 章「视图必须 include 主题 header/footer」
- 第 11 章「CSS/JS 走 Hook 注入」
- 第 18 章交付前自检清单(逐条打钩)
不要修改任何核心文件。
完成后给出完整文件列表和每个文件的完整代码。
20.2 修改已有插件
请在现有插件 XXX 的基础上增加「……功能」:
【附件:LiteLog 插件开发规范 v2.0 完整版.md】
现有插件文件:(贴代码或说明路径)
20.3 排查 500 错误
我的插件启用后后台 500。请根据规范第 1 章排查。
【附件:LiteLog 插件开发规范 v2.0 完整版.md】
报错信息:……
20.4 排错通用模板(★ v2.0 新增)
我的插件出现以下症状:……
【附件:LiteLog 插件开发规范 v2.0 完整版.md】
当前代码:(贴主文件 + 视图 + 路由)
内核相关信息:(贴相关内核文件,如 dispatcher.php / 主题 header.php)
请按以下顺序排查:
1. 首先读内核源码确认机制(不要猜)
2. 然后检查代码是否符合机制
3. 给出最小修改方案
4. 给出验证方法
20.5 只写一个独立函数 / AJAX 端点
请写一个 LiteLog 的 AJAX 端点,实现「……」:
【附件:LiteLog 插件开发规范 v2.0 完整版.md】
要求:走 public_action_xxx 钩子,返回 JSON。
参考第 14.4 节「AJAX 端点八条铁律」。
收尾:一句话总结
「define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require。」
外加三条 v2.0 核心原则:
视图 include 主题 header,资源走 Hook 注入,机制不明先读源码。
掌握这 8+3 条 + 三条硬性规则 + 八条红线,99% 的插件能一次跑通。
本文件基于 LiteLog v1.0.0 内核(2026-10-10 导出)逐行核对整理。v2.0 相对 v1.2 的主要修正:
- PluginRegistry 真实存在(第 7 章)
- 内核只自动加载主文件(第 4 章)
- 生命周期文件是
{插件名}_callback.php(第 6 章) - 前台视图必须 include 主题 header/footer(第 10 章)
- CSS/JS 通过 Hook 注入(第 11 章)
$DB->fetchOne()失败返回false;insert()返回字符串;update()/delete()返回true(第 9 章)- 核心表名:
tag_blog(不是tag_relation),sort主键是sid(第 9 章) - Option 键名:
site_title(不是site_name)(第 9 章) - 新增第 16 章「调试方法」、第 19 章「开发经验教训」
若内核更新,请同步更新第 7 章「注册表」、第 8 章「钩子系统」、第 9 章「数据库」、第 14 章「API 参考」四章。