版本:5.0(2026-10-18) 性质:合并 v3.4 规范与 v4.0 手册,并与 LiteLog 内核 v2.1 源码逐行核对。AI 只读此文件即可开发插件。 结构参考 FlintHub 插件规范(总则 → 目录 → 钩子 → 数据库 → 视图 → 安全 → 调试 → 骨架 → 自检 → 附录)。 核对基准:init.php/include/lib/dispatcher.php/include/lib/hook.php/include/lib/plugin_registry.php/include/lib/function.common.php/admin/index.php/admin/views/layout.php/include/lib/mysql.php/include/lib/option.php/include/lib/csrf.php/include/lib/auth.php/include/lib/url.php/include/lib/view.php/include/lib/output.php/include/lib/widget.php。
目录
- 第 1 章 总则:必读的硬性规则
- 第 2 章 目录结构与自动加载真相
- 第 3 章 主文件与生命周期
- 第 4 章 内核架构与请求生命周期
- 第 5 章 路由系统
- 第 6 章 PluginRegistry 注册表
- 第 7 章 钩子系统全表
- 第 8 章 数据库与模型
- 第 9 章 视图与模板
- 第 10 章 资源加载
- 第 11 章 CSRF 与权限
- 第 12 章 小工具与首页模式
- 第 13 章 安全与陷阱
- 第 14 章 调试与诊断
- 第 15 章 完整最小骨架
- 第 16 章 交付前自检清单
- 第 17 章 三十八条铁律
- 第 18 章 给 AI 的指令模板
- 第 19 章 附录
第 1 章 总则:必读的硬性规则
1.1 三条硬性安全规则(一票否决)
规则 1 — 文件操作,无 basename 不落盘
$safeName = basename($userInput);
if ($safeName === '' || $safeName !== $userInput) return false;
$safePath = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $safeName;
if (is_file($safePath)) unlink($safePath);
规则 2 — 数据库查询,无预处理不执行
// ✅ 占位符
$row = $DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=?", [(int)$gid]);
// ✅ 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);
}
// ❌ 禁止字符串拼接
$DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=$gid");
规则 3 — 输出字符串必须转义
echo '<h1>' . h($log['title']) . '</h1>'; // ✅
echo '<h1>' . $log['title'] . '</h1>'; // ❌
1.2 九条最高级别红线
| # | 红线 | 说明 |
|---|---|---|
| 1 | 严禁反序列化 / 动态执行 | 禁 unserialize() / eval() / assert() / call_user_func($_GET[...]) |
| 2 | 严禁滥用绝对路径 | 只用 LITELOG_ROOT / CONTENT_PATH / ADMIN_PATH / INCLUDE_PATH / DIR |
| 3 | 渲染钩子内禁止中断页面 | layout_* / template_* / nav_* 内禁 die() / exit() / header(),必须 return |
| 4 | 上传必须白名单 + 随机名 | 校验 getimagesize(),名用 bin2hex(random_bytes(16)) |
| 5 | 数量扣减必须原子更新 | UPDATE ... WHERE stock>0 + rowCount() |
| 6 | IN (...) 必须强转 + 占位符 | array_map('intval') + ? |
| 7 | PDO 属性不得覆盖 | 内核已设 ERRMODE_EXCEPTION / EMULATE_PREPARES=false |
| 8 | AJAX 写操作必须有登录校验 | 且必须先登录后 CSRF |
| 9 | 所有表单 / AJAX URL 必须绝对路径 | BLOG_URL . '?...',禁 ?action=xxx |
1.3 500 免疫的十条口诀
define 判 defined,function 判 exists,
Option 用空串,callback 整段包,
DDL 去 COMMENT,视图先守卫,
Hook 也包 try,routes 要 require,
PHP 标签别写乱,顶层副作用莫要犯。
对应十宗罪(按严重程度排序):
| # | 罪状 | 反例 | 正例 |
|---|---|---|---|
| 1 | 主文件顶层 define() / function 无守卫 | define('X','1'); function f(){} | if(!defined('X'))define('X','1'); if(!function_exists('f')){function f(){}} |
| 2 | Option::get() 第二参数传 null | Option::get('x', null) | Option::get('x', '') |
| 3 | 生命周期 DDL 用 COMMENT / COLLATE / 无 try/catch | 见 §3.3 | 见 §3.3 |
| 4 | 视图直接调用插件函数无 function_exists 守卫 | 见 §9.1 | 见 §9.1 |
| 5 | Hook::addAction() 无 try/catch | Hook::addAction('x','y') | try{Hook::addAction(...)}catch(Throwable $e){...} |
| 6 | PluginRegistry 调用无 class_exists 守卫 | 直接调用 | if(class_exists('PluginRegistry')){...} |
| 7 | frontend/routes.php / admin/register.php 既不在约定路径,主文件也没 require | 忘写文件 | 放约定路径(内核会加载)或主文件 require_once |
| 8 | PHP 标签混乱(连续两个 <?php) | 见 §9.5 | 合并为一个大 PHP 块 |
| 9 | 主文件顶层副作用(查库 / IO / HTTP / 重循环 / Option::set) | 见 §3.4 | 移到 register / routes / controller |
| 10 | 视图 / 控制器里 $this 用法错误(见 §7.4) | $this->e() | h() |
第 2 章 目录结构与自动加载真相
2.1 标准目录
content/plugins/{插件名}/
├── {插件名}.php ← 【必须】主文件(内核无条件加载)
├── {插件名}_callback.php ← 【可选】生命周期(内核在启用/停用时主动 require)
├── {插件名}_setting.php ← 【可选】设置页(通过 ?action=plugin_setting 访问)
├── admin/
│ ├── register.php ← 【可选】后台注册(内核在 admin context 自动加载)
│ ├── pages/ ← 后台视图
│ └── assets/ ← 后台资源
├── frontend/
│ └── routes.php ← 【可选】前台路由(内核在 frontend context 自动加载)
├── views/ ← 前台视图
├── assets/ ← 静态资源
└── data/ ← 私有数据(必须带 .htaccess)
2.2 自动加载真相(★ 与 v3.3 修正)
内核 loadActivePlugins() 的真实行为(include/lib/function.common.php):
| 文件 | 自动加载? | 条件 |
|---|---|---|
{插件名}.php | ✅ 是 | 所有 context |
{插件名}_callback.php | ❌ 否 | 内核在启用/停用/卸载时主动 require |
{插件名}_setting.php | ❌ 否 | 通过 ?action=plugin_setting&name=xxx 访问 |
admin/register.php | ✅ 是 | admin context 或 all |
frontend/routes.php | ✅ 是 | frontend context 或 all |
views/*.php | ❌ 否 | 控制器 / 路由回调主动 include |
assets/* | ❌ 否 | Hook 或视图引用 |
结论:
- ✅ 推荐做法:把
admin/register.php和frontend/routes.php放在约定路径,让内核自动加载,主文件不要再重复require(虽然require_once幂等,但会让人困惑)。 - ✅ 稳健做法:主文件里用
require_once DIR . '/frontend/routes.php'(配合is_file判断)。两种做法都合规。 - ⚠️ v3.3 的"罪 7"表述为"子文件必须主文件主动 require"不准确——放在约定路径时内核会自动加载。
2.3 插件名约束(新增)
插件名同时是目录名和伪静态 URL 前缀(/插件名/xxx),不得与以下名字冲突:
| 类型 | 禁用清单 | 原因 |
|---|---|---|
| 真实目录 | admin / content / include / backup / cache / uploads / plugins / templates / assets / data | Web 服务器会把 /admin/ 当成真实目录 |
| 核心伪静态前缀 | post / sort / tag / author / record / page | dispatcher.php 的 matchPluginUri() 会排除这些前缀 |
推荐命名:daily_article / forum / user_center / friend_links / icon_generator,避免 1-2 字符的极短名。
2.4 data/ 目录保护
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>
第 3 章 主文件与生命周期
3.1 主文件标准骨架
<?php
/**
* Plugin Name: 我的插件
* Description: 一句话描述
* Version: 1.0.0
* Author: 你的名字
* Author URI: https://example.com
*/
if (!defined('LITELOG_ROOT')) exit('error!');
/* ① 常量守卫 */
if (!defined('MP_VER')) define('MP_VER', '1.0.0');
/* ② 工具函数(必须 function_exists 守卫) */
if (!function_exists('MP_isEnabled')) {
function MP_isEnabled() {
if (!class_exists('Option')) return true;
try { return Option::get('my_plugin_enabled', '1') === '1'; }
catch (Throwable $e) { return true; }
}
}
/* ③ 注册 Hook(class_exists + try/catch 包裹) */
if (class_exists('Hook')) {
try {
Hook::addAction('layout_head_end', 'MP_injectCss');
Hook::addAction('layout_footer_scripts', 'MP_injectJs');
} 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
);
}
}
/* ④ 子文件加载(可选:放约定路径时内核会自动加载,见 §2.2) */
if (class_exists('PluginRegistry')) {
try {
$__routes = __DIR__ . '/frontend/routes.php';
if (is_file($__routes)) require_once $__routes;
} catch (Throwable $e) { /* 写日志 */ }
}
3.2 头注释字段
| 字段 | 说明 |
|---|---|
Plugin Name / Name | 插件显示名(中文,展示在后台) |
Description | 一句话描述 |
Version | 版本号 |
Author | 作者 |
Author URI / Author URL | 作者网站 |
3.3 生命周期 callback 模板
<?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 $k => $v) {
$optKey = 'my_plugin_' . $k;
if (Option::get($optKey, '') === '') Option::set($optKey, $v);
}
}
} 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) {}
// ② 清 option(按前缀)
try { $DB->delete('option', "option_name LIKE 'my_plugin_%'"); } catch (Throwable $e) {}
// ③ 清缓存文件(如果插件用了跨请求缓存)
try {
$files = glob(CONTENT_PATH . 'cache/my_plugin_*');
if ($files) foreach ($files as $f) @unlink($f);
} catch (Throwable $e) {}
}
铁律:
callback_init()整段 try/catch- DDL 无
COMMENT/COLLATE callback_rm()不删数据(临时禁用)callback_uninstall()顺序:先删表 → 再清 option → 再清缓存文件- 生命周期文件不输出任何内容
3.4 主文件顶层禁止副作用
主文件被所有上下文(前台、后台、CLI)无条件加载,顶层副作用会强加给全站每个请求。以下禁止:
// ❌ 禁止:顶层查库
$count = $DB->fetchColumn("SELECT COUNT(*) FROM ...");
// ❌ 禁止:顶层读文件
$config = json_decode(file_get_contents(__DIR__ . '/data.json'), true);
// ❌ 禁止:顶层发起 HTTP
$resp = file_get_contents('https://api.example.com/xxx');
// ❌ 禁止:顶层执行重循环
for ($i = 0; $i < 10000; $i++) { ... }
// ❌ 禁止:顶层修改 Option(污染全站)
Option::set('site_title', 'xxx');
// ✅ 允许:常量守卫、函数定义、Hook::addAction 注册、require 子文件
判定原则:顶层只做"声明性"的事。任何"执行性"的事都移到 admin/register.php、frontend/routes.php、callback_init() 或控制器里。
第 4 章 内核架构与请求生命周期
4.1 分层结构
请求 → index.php / admin/index.php
↓
init.php
├─ 定义常量(LITELOG_ROOT / CONTENT_PATH / ADMIN_PATH / INCLUDE_PATH)
├─ require config.php → 定义 DB_PREFIX
├─ 启动 Session(HttpOnly + SameSite=Lax)
├─ require 所有 lib/*.php
├─ 建立 PDO($DB 全局)
├─ Option::init() 加载配置
├─ require 所有 model / controller
├─ 定义 BLOG_URL
├─ ★ 维护模式检查(site_maintenance='y' 时拦截)
├─ ★ 移动端 router(isMobileDevice() == true 时加载,可能 exit)
└─ loadActivePlugins() ← 你的插件在这里加载
↓
Dispatcher::dispatch()(前台)
├─ Hook::doAction('dispatch_start')
├─ ★ matchPluginUri():识别 /插件名/xxx → __plugin_path
├─ dispatch_plugin_route(若有 __plugin_path)
├─ 标准 Rewrite URL(post/sort/tag/author/record/page)
├─ PluginRegistry::matchFrontendRoute()
├─ public_action_{action}
├─ dispatch_matched → 控制器执行 → View::display()
└─ dispatch_end(shutdown 兜底)
4.2 后台完整流程
admin/index.php
├─ 定义 LITELOG_ROOT,require init.php
├─ require admin/helpers.php
├─ 处理 action=login / logout
├─ checkLogin() 强制登录(未登录 → redirect(BLOG_URL . 'admin/?action=login'))
├─ ★ CSRF 校验(POST 且非插件页面/设置页 → Csrf::verify())
├─ Hook::doAction('admin_request', $action)
├─ PluginRegistry::renderAdminPage($action)(若注册过)
├─ PluginRegistry::renderPluginSetting($name)(action=plugin_setting)
└─ 内置路由 switch
关键点:
- 插件后台页面(
PluginRegistry::addAdminPage()注册的)跳过核心全局 CSRF 校验,必须自行校验。 - 插件设置页(
action=plugin_setting)同理,必须自行校验。
4.3 移动端 router 抢跑
init.php 里先加载 mobile router,后加载插件:
if (function_exists('isMobileDevice') && isMobileDevice()) {
$__mobileRouter = CONTENT_PATH . 'templates/' . getCurrentTemplateName() . '/mobile/router.php';
if (is_file($__mobileRouter)) {
try { require_once $__mobileRouter; }
catch (Throwable $__e) { /* 写日志 */ }
}
}
loadActivePlugins();
影响:mobile/router.php 里的 exit 分支会让 loadActivePlugins() 根本不执行 → 插件钩子全部失效。
应对:
- 方案 A(推荐):插件路由用
PluginRegistry::addFrontendRoute(),在dispatch()内部匹配,不受 mobile router 影响。 - 方案 B:核心
matchPluginUri()(v2.1 已内置)在dispatch_start后立即识别/插件名/xxx,时机在 mobile router 之后,也不受影响。 - 方案 C:让
mobile/router.php未匹配时return而非exit。
4.4 会话安全
| 项 | 值 |
|---|---|
| Cookie HttpOnly | true |
| Cookie SameSite | Lax |
| Cookie Secure | HTTPS 自动开启 |
use_strict_mode | 1 |
session_regenerate_id(true) | 登录成功后调用 |
| Session 名 | LITELOG_SID |
第 5 章 路由系统
5.1 三种路由方式
| 方式 | 触发 | 适用 |
|---|---|---|
dispatch_plugin_route 钩子 | 核心 matchPluginUri() 识别 /插件名/xxx | 推荐——简洁、自动 |
PluginRegistry::addFrontendRoute($regex, $cb) | 核心 matchFrontendRoute() 匹配正则 | 需要自定义 URL 模式 |
public_action_xxx 钩子 | ?action=xxx | 传统方式、兼容旧版本 |
5.2 核心伪静态识别(★ v2.1 已内置,无需打补丁)
dispatcher.php 的 matchPluginUri() 已实现自动识别 /插件名/xxx:
- 排除核心前缀
post/ sort/ tag/ author/ record/ page/ - 检查
is_file(CONTENT_PATH . 'plugins/' . $name . '/' . $name . '.php') - 匹配成功则设置
$_GET['__plugin_path']和$_GET['__plugin_rest']
受 plugin_rewrite 开关控制(默认 'y' 开启):
$pluginRewriteOn = (Option::get('plugin_rewrite', 'y') === 'y');
关闭后所有 /插件名/xxx 形式 URL 不识别,全部回退到 ?action= 形式。
5.3 dispatch_plugin_route 分发
Hook::addAction('dispatch_plugin_route', function ($pluginName, $rest) {
if ($pluginName !== 'daily_article') return;
$GLOBALS['__plugin_routed'] = true;
$rest = trim((string)$rest, '/');
if ($rest === '' || $rest === 'list') { (new DailyController())->displayIndex(); return; }
if ($rest === 'archive') { (new DailyController())->displayArchive(); return; }
if (preg_match('|^detail/(\d+)$|', $rest, $m)) {
$_GET['id'] = (int)$m[1];
(new DailyController())->displayDetail((int)$m[1]);
return;
}
$GLOBALS['__plugin_routed'] = false;
});
铁律:
$GLOBALS['__plugin_routed'] = true放最前- 每个分支最后必须
exit或return - 找不到匹配时设回
false,让核心继续处理
5.4 URL 生成函数(推荐)
if (!function_exists('MP_url')) {
function MP_url($sub = '', array $params = []) {
$useRewrite = class_exists('Url') && Url::isPluginRewriteOn();
if ($useRewrite) {
$base = rtrim(BLOG_URL, '/') . '/my_plugin';
if ($sub !== '') $base .= '/' . ltrim($sub, '/');
if (!empty($params)) $base .= '?' . http_build_query($params);
return $base;
}
return BLOG_URL . '?' . http_build_query(
array_merge(['action' => 'my_plugin'], $params, $sub ? ['sub' => $sub] : [])
);
}
}
5.5 plugin_rewrite 开关
plugin_rewrite | 前台 URL 形式 | 推荐场景 |
|---|---|---|
'y'(默认) | /插件名/xxx | 已配置 Nginx 重写规则 |
'n' | ?action=xxx | 未配置服务器重写,任意服务器可用 |
第 6 章 PluginRegistry 注册表
6.1 API
| 方法 | 用途 |
|---|---|
addAdminPage($action, $config) | 注册后台页面 |
hasAdminPage($action) | 是否存在 |
getAdminPage($action) | 获取配置 |
addAdminMenu($menu) | 注册侧边栏菜单 |
getAdminMenus() | 获取所有菜单 |
addFrontendRoute($regex, $cb) | 注册前台路由 |
matchFrontendRoute($uri) | 匹配前台路由(内核调用) |
addAdminAsset($action, $type, $url) | 注册后台资源 |
enqueueAsset($type, $url) | 手动暂存资源 |
getPendingAssets($type) | 读取暂存资源 |
renderAdminPage($action) | 渲染后台页面(内核调用) |
renderPluginSetting($name) | 渲染设置页(内核调用) |
6.2 注册后台页面
// 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 () {
if (class_exists('Auth')) Auth::requireCap('plugin.manage');
include __DIR__ . '/pages/list.php';
},
]);
} catch (Throwable $e) { /* 写日志 */ }
可用 icon:dashboard / file / comment / folder / settings / external / logout / plus / edit / trash / plugin / palette / check / x / refresh
6.3 插件设置页
设置页放在 {插件名}_setting.php,通过核心入口访问:
<?= BLOG_URL ?>admin/?action=plugin_setting&name=xxx
内核做的事:
- 校验插件名正则
^[a-zA-Z0-9_\-]+$ - 强制
Auth::requireCap('plugin.manage') define('IN_PLUGIN_SETTING', true)- 渲染
{插件名}_setting.php(自动包上 layout)
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!defined('IN_PLUGIN_SETTING')) {
http_response_code(404);
exit('Not Found');
}
if (!function_exists('MP_isEnabled')) { echo '插件未加载'; return; }
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (class_exists('Csrf')) Csrf::verify();
// ★ 字段缺失跳过(详见 §13.5)
if (array_key_exists('title', $_POST)) {
Option::set('my_plugin_title', trim($_POST['title']));
}
redirect(BLOG_URL . 'admin/?action=plugin_setting&name=my_plugin&saved=1');
}
?>
<h2>我的插件设置</h2>
<?php if (!empty($_GET['saved'])): ?>
<div class="card" style="background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46">设置已保存</div>
<?php endif; ?>
<form method="post" class="form">
<?= Csrf::field() ?>
<div class="field">
<label>插件标题</label>
<input type="text" name="title" value="<?= h(Option::get('my_plugin_title', '')) ?>">
</div>
<button class="btn" type="submit">保存</button>
</form>
6.4 前台路由注册
// frontend/routes.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;
try {
PluginRegistry::addFrontendRoute('icon/?', 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/generator.php';
});
} catch (Throwable $e) { /* 写日志 */ }
注意:addFrontendRoute 的分桶索引按正则首段提取(^forum/ → 'forum' 桶),matchFrontendRoute() 优先精确桶后通配桶,性能 O(1)。
6.5 后台资源
PluginRegistry::addAdminAsset('my_plugin', 'css', BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.css');
PluginRegistry::addAdminAsset('my_plugin', 'js', BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.js');
第 7 章 钩子系统全表
7.1 Hook API
Hook::addAction($hook, $func, $priority = 10); // 注册(越小越先执行)
Hook::doAction($hook, ...$args); // 触发
Hook::removeAction($hook, $func = null); // 移除
Hook::hasAction($hook); // 是否挂载
Hook::captureAction($hook, ...$args); // 捕获输出
Hook::applyFilters($hook, $value, ...$args); // 过滤器
| 语义 | 方法 | 参数传递 |
|---|---|---|
| Action(通知型) | doAction | 引用传参改数据:function (&$data, ...) |
| Filter(过滤器) | applyFilters | 第一个参数是值,返回值替换 |
7.2 钩子全表(★ 按触发点核对)
A. 生命周期
| 钩子 | 触发点 | 参数 |
|---|---|---|
plugin_activate | admin/index.php 启用插件后 | $name |
plugin_deactivate | admin/index.php 停用插件后 | $name |
plugin_uninstall | admin/index.php 卸载插件后 | $name |
B. 前台布局锚点(主题侧钩子,核心主题已实现)
| 钩子 | 触发点 | 推荐用途 |
|---|---|---|
layout_head_start | <head> 最前 | 早期 meta / preload |
layout_head_end | </head> 前 | 加载 CSS、注入 <link> |
layout_body_start | <body> 之后 | 顶部横幅 |
layout_content_start | <main> 之后 | 面包屑 |
layout_content_end | 内容后、</main> 前 | 相关文章 |
layout_footer_scripts | </body> 前脚本区 | 加载 JS |
layout_body_end | </body> 之前 | 悬浮元素 |
C. 传统布局钩子(兼容老主题)
template_head / body_start / header_before / header_after / before_content / after_content / footer_before / footer_after / template_footer / body_end
D. 视图钩子
| 钩子 | 触发点 | 参数 |
|---|---|---|
view_before | View::display() 前 | $name, $data |
view_after | View::display() 后 | $name, $data |
E. 分发路由
| 钩子 | 触发点 | 参数 |
|---|---|---|
dispatch_start | Dispatcher::dispatch() 开头 | 无 |
dispatch_plugin_route | __plugin_path 分发 | $pluginName, $rest |
dispatch_matched | 路由匹配后 | &$matched |
dispatch_end | 分发结束(shutdown 兜底) | 无 |
public_action_{action} | ?action=xxx 分发 | 无 |
F. 内容(列表页)
| 钩子 | 触发点 | 参数 |
|---|---|---|
log_list_query | 列表查询前 | &$cond, $page, $perPage |
log_list_result | 列表查询后 | &$logs, $page, $total |
sort_display_query | 分类页查询前 | $sort, &$cond, $page, $perPage |
sort_display | 分类页渲染前 | $sort, $logs |
tag_display_query | 标签页查询前 | $tag, &$cond, $page, $perPage |
tag_display | 标签页渲染前 | $tag, $logs |
record_display_query | 归档页查询前 | $year, $month, &$cond, $page, $perPage |
record_display | 归档页渲染前 | $year, $month, $logs |
search_before | 搜索前 | $keyword |
search_result | 搜索结果后 | $keyword, $logs |
index_head | 首页头部 | 无 |
G. 内容(详情页)
| 钩子 | 触发点 | 参数 |
|---|---|---|
log_display_before | 详情渲染前 | $log |
log_display_after | 详情渲染后 | $log |
log_content | 正文输出 | $log |
log_save_before | 文章保存前(Filter) | $data, $gid |
log_save_after | 文章保存后 | $gid, $data |
admin_log_save_before | 后台文章保存前(Filter) | $data, $gid |
admin_log_save_after | 后台文章保存后 | $gid, $data |
admin_log_edit_sidebar | 后台文章编辑侧边栏 | $log |
comment_save_before | 评论提交前(Filter) | $data |
comment_save_after | 评论插入后 | $cid, $data |
comment_post | 评论提交后(兜底) | $data |
H. 后台
| 钩子 | 触发点 | 参数 |
|---|---|---|
admin_request | admin/index.php POST 处理前 | $action |
admin_menu | admin/views/layout.php 侧边栏 | $activeKey |
admin_head | layout.php <head> | 无 |
admin_footer | layout.php </body> 前 | 无 |
admin_dashboard_widgets | 概览页 | 无 |
I. 扩展点
| 钩子 | 用途 |
|---|---|
register_widgets | 注册小工具 |
register_homepage_modes | 注册首页模式 |
J. 系统事件
| 钩子 | 触发点 | 参数 |
|---|---|---|
user_login | 登录成功后 | $user |
user_logout | 登出时 | $uid |
theme_activate | 切换主题后 | $name, $old |
option_save | 保存 option 后 | $key, $value |
K. 主题侧钩子(在 content/templates/ 主题文件中触发,核心代码本身不触发)
comment_section_before / comment_section_after(评论区前后,$log)—— 需主题实现。
7.3 钩子注册规范
所有 Hook::addAction() 必须:
if (class_exists('Hook')) {
try {
Hook::addAction('xxx', 'my_callback');
} 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
);
}
}
插件自己触发供其它插件监听的钩子必须加插件名前缀:
Hook::doAction('my_plugin_after_save', $id, $data);
7.4 钩子回调作用域与 $this(★ 必读)
Hook::doAction 内部用 call_user_func_array($func, $args) 调用,回调在静态方法上下文中执行:
- 回调是普通函数名或匿名函数:
$this不可用 - 回调是对象方法(
[$obj, 'method']):$this指向$obj(不推荐)
症状:钩子里写 $this->e() / $this->getData() / $this->icon() → PHP 8 抛 Using $this when not in object context → 被 Hook::doAction 的 try/catch 吞成"HTML 渲染到一半断了、HTTP 仍 200"。
正确写法:
| 视图里 | 钩子里 |
|---|---|
$this->e($s) | h($s) 或 htmlspecialchars($s, ENT_QUOTES, 'UTF-8') |
$this->url('/path') | BLOG_URL . 'path' 或 MP_url(...) |
$this->icon($name) | 直接 adminIcon($name)(后台)或 Font Awesome 字符实体 |
$this->getData('key') | $GLOBALS['key'] ?? null 或由钩子参数传入 |
排查口诀:插件区块半截输出 → 先看 error.log 有没有 Using $this。
7.5 双触发 / 重复触发陷阱
| 钩子 | 触发次数 | 注意 |
|---|---|---|
dispatch_plugin_route | 1 次 | 正常 |
public_action_xxx | 1 次 | 正常 |
log_list_query / log_list_result | 1 次/页 | 正常 |
view_before / view_after | 每次 View::display() 各 1 次 | 页内多次 View::display() 会多次触发 |
Hook::addAction 重复调用会追加,不会覆盖——注意不要在每次请求中重复注册(除非用 static 守卫或 Hook::removeAction 清空)。
第 8 章 数据库与模型
8.1 Database 类精确签名
| 方法 | 返回值 | 陷阱 |
|---|---|---|
query($sql, $params = []) | PDOStatement | 可 ->rowCount() |
fetchAll($sql, $params = []) | array(空时 []) | — |
fetchOne($sql, $params = []) | array \| false | 失败返回 false,不是 null |
fetchColumn($sql, $params = []) | 标量 | 空结果返回 false,不是 0 |
insert($table, $data) | string(lastInsertId) | 必须 (int) 转换 |
update($table, $data, $where, $whereParams = []) | true | 不是行数!要行数用 query()->rowCount() |
delete($table, $where, $params = []) | true | 同上 |
8.2 使用示例
global $DB;
// 查一条(判 false)
$row = $DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "user` WHERE uid=?", [(int)$uid]);
if ($row === false) { /* 不存在 */ }
// 查多条
$rows = $DB->fetchAll("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `sortid`=? LIMIT 10", [(int)$sid]);
// 标量
$count = (int)$DB->fetchColumn("SELECT COUNT(*) FROM `" . DB_PREFIX . "user` WHERE `status`=1");
// 插入((int) 转换)
$newId = (int)$DB->insert('my_items', ['name' => 'test', 'created_at' => time()]);
// 更新
$DB->update('my_items', ['name' => 'new'], 'id = ?', [(int)$id]);
// 删除
$DB->delete('my_items', 'id = ?', [(int)$id]);
// 原子扣减(判 rowCount)
$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' => '余额不足']; }
// 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);
}
8.3 事务
$pdo = $DB->pdo();
$pdo->beginTransaction();
try {
// ... 业务 ...
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack(); // ★ 必须判 inTransaction
throw $e;
}
注意:$DB->pdo() 返回原生 PDO;begin() / commit() 不存在,用 beginTransaction() / commit()。
8.4 核心表清单
| 表 | 关键字段 |
|---|---|
{DB_PREFIX}user | uid(PK) / username / nickname / email / password / role |
{DB_PREFIX}blog | gid(PK) / title / content / content_md / alias / sortid / author / date / views / comnum / hide('y'/'n') / type('blog'/'page') / top(TINYINT) / cover / excerpt |
{DB_PREFIX}comment | cid(PK) / gid / pid / poster / email / url / comment / ip / agent / date / hide('y'待审/'n'通过) |
{DB_PREFIX}sort | sid(PK) / sortname / alias / pid / description |
{DB_PREFIX}tag / {DB_PREFIX}tag_blog | 标签关联 |
{DB_PREFIX}option | id(PK) / option_name(UNIQUE) / option_value(TEXT) / autoload |
Option::get() 返回永远是 string,判断用 === '1' / === 'y'。
8.5 核心模型速查
| 模型 | 主要方法 |
|---|---|
LogModel | query / getOne / getByAlias / latest / prev / next / add / update / delete / addView / count / search |
SortModel | all / getOne / getByAlias / add / update / delete / countInSort |
TagModel | all / getByName / getByGid / getGidsByTag / attach / detach |
CommentModel | getOne / byLog / byLogPaginated / buildTree / latest / add / delete / deleteMany / countByLog / refreshComnum / hasRecentDuplicate |
UserModel | getOne / getByUsername / login / update |
LinkModel | all |
8.6 Option 配置存储
Option::set('my_plugin_enabled', '1');
$v = Option::get('my_plugin_enabled', ''); // ★ 第二参数永远 ''
Option::set('my_plugin_config', json_encode(['a' => 1], JSON_UNESCAPED_UNICODE));
$cfg = json_decode(Option::get('my_plugin_config', '{}'), true);
// 批量预取(≥ 3 个 key 时用,把 N 次查询压成 1 次)
Option::preload(['my_plugin_a', 'my_plugin_b', 'my_plugin_c']);
$a = Option::get('my_plugin_a', '');
Option::set() 第三参数 $autoload(v2.1 新增):
null(默认):关键 key →'y',其余 →'n''y':强制 autoload(每次请求都读入内存)'n':强制不 autoload(按需读)
第 9 章 视图与模板
9.1 前台视图必须 include 主题 header/footer
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
/* 前置守卫 */
if (!function_exists('MP_isEnabled')) {
echo '<div class="error">插件未正确加载</div>';
return;
}
/* 三变量兜底(见 §9.2) */
$page_title = '我的插件 - ' . Option::get('site_title', '');
$title = $page_title;
$__pageTitle = $page_title;
include View::resolve('header');
?>
<div class="my-container">
... 页面内容 ...
</div>
<?php include View::resolve('footer'); ?>
原理:View::resolve('header') 优先查找 templates/{主题}/mobile/header.php,找不到回退到 templates/{主题}/header.php。
9.2 三变量兜底 + JS 覆盖 document.title
问题:主题 header 变量名不可预测,加上 Cookie litelog_view=mobile 可能让 PC 浏览器走移动端模板,导致标题显示站点名。
铁律:视图顶部同时设置 $page_title / $title / $__pageTitle,并加 JS 覆盖:
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
$page_title = '我的插件 - ' . Option::get('site_title', '');
$title = $page_title;
$__pageTitle = $page_title;
/* ★ 终极方案:JS 强制覆盖 document.title */
if (empty($GLOBALS['__mp_title_hooked'])) {
$GLOBALS['__mp_title_hooked'] = true;
$__mp_title = $page_title;
if (class_exists('Hook')) {
try {
$__mp_js = '<script>(function(){var t='
. json_encode($__mp_title, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)
. ';if(document.title!==t){document.title=t;}})();</script>' . "\n";
Hook::addAction('layout_head_end', function () use ($__mp_js) {
if (empty($GLOBALS['__mp_active'])) return;
echo $__mp_js;
});
Hook::addAction('template_head', function () use ($__mp_js) {
if (empty($GLOBALS['__mp_active'])) return;
echo $__mp_js;
});
} catch (Throwable $e) {}
}
}
include View::resolve('header');
?>
为什么绝对有效:
| 主题 header 读什么 | 效果 |
|---|---|
$page_title | ✅ 已经对 |
$title | ✅ 已经对 |
$__pageTitle | ✅ 已经对 |
| 硬编码站点名 | ✅ JS 覆盖 document.title |
| 什么都不输出 | ✅ 有 <title> 才覆盖 |
| 用了缓存/CDN | ✅ 只影响客户端显示,绕过服务端缓存 |
9.3 评论表单必须加 CSRF
<form method="post" action="<?= BLOG_URL ?>?action=comment_post">
<?= Csrf::field() ?>
<input type="hidden" name="gid" value="<?= (int)$log['gid'] ?>">
<input type="hidden" name="pid" value="0">
<textarea name="comment" required></textarea>
<button type="submit">提交</button>
</form>
9.4 后台视图
后台视图由 PluginRegistry::addAdminPage 的 callback 调用,内核已包好 admin/views/layout.php,不要 include 主题 header/footer。
可用核心 CSS 类:card / card-title / btn / btn-outline / btn-danger / btn-sm / table-wrap / table / form / field / tag / empty / error / pagination
9.5 PHP 语法安全写法
危险写法(连续两个 <?php):
<?php if (!defined('LITELOG_ROOT')) exit('error!'); ?>
<?php
/* 大量代码 */
只要用户在编辑器粘贴时第二个 <?php 丢失,或被转义成 <?php(富文本编辑器常见),就会:
syntax error, unexpected '<', expecting end of file
安全写法:合并为一个大 PHP 块,只在输出 HTML 时才闭合 ?>。
验证:
php -l content/plugins/your_plugin/views/detail.php
# 期望:No syntax errors detected
9.6 输出转义
<h1><?= h($log['title']) ?></h1>
<a href="<?= h($url) ?>"><?= h($name) ?></a>
第 10 章 资源加载
10.1 前台资源用 layout_* 注入
if (class_exists('Hook')) {
try {
Hook::addAction('layout_head_end', 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";
});
Hook::addAction('layout_footer_scripts', 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) { /* 写日志 */ }
}
视图里标记:
$GLOBALS['__mp_active'] = true;
include __DIR__ . '/../views/main.php';
兼容老主题:两个 Hook 都注册,用 static 去重:
Hook::addAction('layout_head_end', 'MP_injectCss');
Hook::addAction('template_head', 'MP_injectCss'); // 老主题兜底
10.2 后台资源
PluginRegistry::addAdminAsset('my_plugin', 'css', BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.css');
PluginRegistry::addAdminAsset('my_plugin', 'js', BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.js');
10.3 数据目录保护
content/plugins/{插件名}/data/ 必须放 .htaccess(见 §2.4)。
10.4 版本号
资源 URL 必须带 ?v=filemtime 或 ?v=版本号,避免浏览器缓存旧资源:
$ver = (int)(@filemtime(__DIR__ . '/assets/style.css') ?: 1);
echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/assets/style.css?v=' . $ver . '">';
第 11 章 CSRF 与权限
11.1 CSRF API
$token = Csrf::token(); // 获取当前会话 token
echo Csrf::field(); // 输出隐藏字段
Csrf::verify(); // 校验(失败直接 403 并输出)
Csrf::verifyJson($payload); // 从 JSON body 校验
11.2 表单必须加 CSRF
任何 POST 表单必须:
<form method="post" action="...">
<?php if (class_exists('Csrf')) echo Csrf::field(); ?>
</form>
核心行为:admin/index.php 对所有 POST 强制 Csrf::verify(),除非该 action 是插件页面(PluginRegistry::hasAdminPage())或插件设置页(action=plugin_setting)——这两种必须插件自行校验。
11.3 AJAX 必须带 X-CSRF-Token
后台 layout.php 已全局包装 window.fetch,同源请求自动加 header。手动:
fetch(url, {
method: 'POST',
headers: {
'X-CSRF-Token': window.LITELOG_CSRF || '',
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
11.4 Auth 权限
Auth::check(); // 是否登录
Auth::id(); // 当前用户 uid
Auth::role(); // admin / editor / author / guest
Auth::can($cap); // 是否有某能力
Auth::requireCap($cap); // 强制校验,失败终止
Auth::canEditLog($log); // 文章所有者检查
Auth::regenerate(); // 会话再生成
角色能力表:
| 角色 | 能力 |
|---|---|
admin | * |
editor | log. / comment. / sort. / tag. / widget.manage / theme.manage / plugin.manage |
author | log.own / comment.reply |
插件后台入口:
if (class_exists('Auth')) Auth::requireCap('plugin.manage');
11.5 登录态失效:先登录后 CSRF(★ 重要)
症状:用户在插件页面停留过久,Session 过期。此时点"提交":
- 如果先 CSRF 校验:CSRF 令牌也失效 → 返回 403 页面,用户卡死
- 如果先判断登录:会话过期时能跳转到登录页,用户重新登录
铁律:写操作先判断登录,后校验 CSRF:
// ❌ 反例:CSRF 先失败
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Csrf::verify(); // 会话过期 → 403
if (!isLoggedIn()) redirect(...); // 永远走不到
}
// ✅ 正例:先判断登录
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isLoggedIn()) {
redirect(BLOG_URL . 'admin/?action=login');
}
Csrf::verify();
}
AJAX 端点同理,登录失效时返回 401 + login_url,让前端跳转:
if (empty($_SESSION['uid'])) {
echo json_encode([
'ok' => false,
'msg' => '登录已过期,请重新登录',
'login_url' => BLOG_URL . 'admin/?action=login',
], JSON_UNESCAPED_UNICODE);
exit;
}
注意:当前核心的 checkLogin() 和 login 分支不带 next 参数(登录成功后固定跳 BLOG_URL . 'admin/')。如果插件想改善体验,可以自己用 $_SESSION['redirect_after_login'] 存来源,登录成功后读取——但这是插件自己的事,不是规范要求。
第 12 章 小工具与首页模式
12.1 自定义小工具(★ 必须声明 $cacheable)
class Widget_MyBlock extends Widget {
public $id = 'my_block';
public $name = '我的模块';
public $description = '展示自定义内容';
public $source = 'plugin:my_plugin'; // 分组用
/* ★ v2.1:必须声明缓存策略
true → 内容稳定,允许缓存(默认 TTL 300 秒)
false → 随机 / 时变内容,每次请求都渲染 */
public $cacheable = true;
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());
});
表单字段必须带 data-setting="键名"(内核 widgets.js 依赖此属性)。
缓存策略判定:
| 场景 | $cacheable |
|---|---|
| 最新文章 / 分类 / 标签 / 归档 | true |
| 随机文章 / 随机标签 | false |
日历(依赖 $_GET['cal']) | false |
| 用户头像 / 在线人数 | false 或 cacheTtl = 60 |
| 广告位 / 文本块 / 自定义 HTML | true |
| 统计数字(文章数、评论数) | true(5 分钟延迟可接受) |
render() 内禁止 N+1 查询:
// ❌ 禁止:循环里查库
foreach ($sorts as $s) {
$cnt = SortModel::countInSort($s['sid']);
}
// ✅ 正例:一次 GROUP BY
$rows = $DB->fetchAll("SELECT sortid, COUNT(*) AS cnt FROM " . DB_PREFIX . "blog
WHERE hide='n' AND type='blog' GROUP BY sortid");
$counts = array_column($rows, 'cnt', 'sortid');
12.2 自定义首页模式
class HomepageMode_MyMode extends HomepageMode {
public $id = 'my_mode';
public $name = '我的模式';
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 章 安全与陷阱
13.1 相对 URL 陷阱(最痛的教训)
症状:删除按钮点了没反应,页面刷新但目标还在。
原因:
<!-- ❌ 反例:相对 URL -->
<form method="post" action="?action=forum_delete&id=5">
在 URL 为 /forum/thread/5 的页面,浏览器把 ?action=... 解析为 http://host/forum/thread/5?action=forum_delete&id=5,被 Nginx 重写规则抢跑,public_action_forum_delete 根本没走到。
铁律:所有表单 / AJAX 的 URL 必须使用绝对路径:
<!-- ✅ 正例 -->
<form method="post" action="<?= BLOG_URL ?>?action=forum_delete&id=5">
JS 动态生成表单同样适用:
form.action = '<?= BLOG_URL ?>?action=forum_thread_mod'; // ✅
兜底:显式读 $_POST 和 $_GET,不依赖 $_REQUEST(受 request_order 配置影响):
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
13.2 移动端 router 抢跑
见 §4.3。
13.3 输出缓冲陷阱
| # | 铁律 |
|---|---|
| 1 | 不要用 ob_start('callback') 绑定回调做页面拦截 |
| 2 | 不要在 ob_start 的回调里再 ob_start(PHP 明确禁止) |
| 3 | 回调必须判 is_string($html),非字符串返回 '' |
| 4 | 优先用 layout_* 锚点,其次 dispatch_end + 手动捕获 |
| 5 | 绝不要在 ob_start 和 ob_get_clean 之间抛异常 |
| 6 | 用 Output::capture() 而不是裸 ob_start / ob_get_clean |
$html = Output::capture(function () use ($data) {
echo '<ul>';
foreach ($data as $row) echo '<li>' . h($row) . '</li>';
echo '</ul>';
});
13.4 后台设置保存:字段缺失跳过
问题:(int)($_POST[$k] ?? 0) 把"字段没来"和"用户填了 0"压成同一个值,导致缺失字段被静默写成 0。
// ❌ 反例:缺失字段被写成 0
foreach (['enabled', 'max_count'] as $k) {
Option::set('my_plugin_' . $k, (int)($_POST[$k] ?? 0));
}
// ✅ 正例:缺失即跳过
foreach (['enabled', 'max_count'] as $k) {
if (!array_key_exists($k, $_POST)) continue; // 缺失 = 跳过
Option::set('my_plugin_' . $k, (int)$_POST[$k]);
}
注意:多选框组(features[])用户全部取消时 $_POST['features'] 不存在,语义是"清空"——这种场景要保留"缺失 = 清空",不要跳过。
13.5 上传路径与站内资源白名单
admin/upload.php 上传成功后返回站内相对路径(带前导 /,不带域名):
/content/uploads/2026-10/20261018120000_abc12345.png
如果插件自己做"图片路径校验",必须同时接受:
if (!function_exists('MP_safeImagePath')) {
function MP_safeImagePath($path) {
$path = trim((string)$path);
if ($path === '' || strlen($path) > 500) return '';
if (preg_match('/[\x00-\x20\x7f\\\\]/', $path)) return ''; // 控制字符 / 反斜杠
if (strpos($path, '//') === 0) return ''; // 协议相对
if (preg_match('#^[A-Za-z][A-Za-z0-9+.\-]*:#', $path)) {
return stripos($path, 'https://') === 0 ? $path : ''; // 只放行 https
}
if (strpos($path, '..') !== false) return ''; // 路径穿越
if (!preg_match('#^/?[A-Za-z0-9_\-./]+$#', $path)) return '';
return $path;
}
}
自建上传时:
- 必须
basename()+ 扩展名白名单 +getimagesize()校验 - 文件名用
bin2hex(random_bytes(16)) . '.' . $ext - 返回 URL 带
?v=filemtime - 强烈建议直接复用
admin/upload.php(已内置白名单 + MIME 校验 + 像素上限 + 随机名)
13.6 其他常见坑
| 坑 | 症状 | 修复 |
|---|---|---|
全角标点紧跟 $var | 消息变成"错误::请重试",无报错 | 用 {$var} 明确边界 |
Hook::addAction 重复调用 | 钩子回调执行 N 次 | 用 static 守卫或 Hook::removeAction |
设置页未定义 IN_PLUGIN_SETTING | 直接访问报错 | 在文件头部检查 defined('IN_PLUGIN_SETTING') |
callback_init() 输出内容 | 启用插件时页面白屏 | 生命周期文件不输出任何内容 |
| 插件名与真实目录冲突 | 访问 /插件名/ 403 / 404 | 见 §2.3 |
第 14 章 调试与诊断
14.1 症状 → 第一步
| 症状 | 第一步 |
|---|---|
| 后台 500 | 打开 display_errors,看报错行号 |
| 前台白屏 | F12 Console 看 JS;Network 看 HTML |
| 路由不生效 | 读 dispatcher.php 的 matchPluginUri() |
| 标题不对 | 读主题 header.php 和 mobile/header.php |
| CSS/JS 没加载 | F12 Network 看状态码 |
| CSRF 校验失败 | 检查表单是否有 Csrf::field();AJAX 是否带 X-CSRF-Token |
| 权限拒绝 | 检查 Auth::can() / Auth::requireCap() 的能力名 |
| 删除/修改无效 | 检查表单 action 是不是相对 URL |
| 语法错误白屏 | php -l 检查每个文件 |
| 钩子区块半截 | 查 plugin-error.log 有没有 Using $this |
14.2 错误日志路径
| 日志 | 内容 |
|---|---|
content/backup/plugin-error.log | 插件主逻辑错误 |
content/backup/hook-error.log | 钩子执行错误 |
content/backup/route-error.log | 路由注册错误 |
content/backup/mobile-router-error.log | 移动端 router 错误 |
content/backup/db-error.log | 数据库连接错误 |
content/backup/query-error.log | 数据库查询错误 |
content/backup/comment-error.log | 评论错误 |
content/backup/probe.log | 探针日志 |
14.3 独立诊断脚本 _diag_plugin.php
<?php
/* 用法:放到网站根目录,浏览器访问 /_diag_plugin.php,用完删除 */
while (ob_get_level() > 0) { @ob_end_clean(); }
if (!defined('LITELOG_ROOT')) {
define('LITELOG_ROOT', __DIR__ . DIRECTORY_SEPARATOR);
}
$_GET['force_view'] = 'desktop';
$_COOKIE['litelog_view'] = 'desktop';
@ini_set('display_errors', '1');
@ini_set('display_startup_errors', '1');
@error_reporting(E_ALL);
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>插件诊断</title>
<style>body{font-family:Consolas,Menlo,monospace;font-size:13px;line-height:1.65;padding:22px;background:#0f172a;color:#e2e8f0}
h1{color:#60a5fa}h2{color:#a78bfa;font-size:15px;margin:26px 0 10px;border-bottom:1px solid #334155}
.tag{padding:1px 9px;border-radius:5px;font-size:11.5px;font-weight:700;margin-right:8px}
.tag-ok{background:#064e3b;color:#6ee7b7}.tag-err{background:#7f1d1d;color:#fca5a5}
.tag-warn{background:#78350f;color:#fcd34d}pre{background:#1e293b;padding:11px;border-radius:7px;white-space:pre-wrap}</style>
</head><body>
<h1>🔍 插件诊断报告</h1>
<p><?= date('Y-m-d H:i:s') ?> · PHP <?= PHP_VERSION ?></p>
<?php
function diag_row($tag, $text) { echo '<div><span class="tag tag-' . $tag . '">' . strtoupper($tag) . '</span>' . $text . '</div>'; }
/* 阶段 1:加载核心 */
echo '<h2>阶段 1 · 加载核心</h2>';
try { require_once LITELOG_ROOT . 'init.php'; diag_row('ok', 'init.php 加载完成'); }
catch (Throwable $e) { diag_row('err', 'init.php 异常:' . htmlspecialchars($e->getMessage())); echo '</body></html>'; exit; }
/* 阶段 2:核心类与常量 */
echo '<h2>阶段 2 · 核心类与常量</h2>';
foreach (['Option','Hook','View','Csrf','Auth','Database','Dispatcher','PluginRegistry'] as $cls) {
diag_row(class_exists($cls) ? 'ok' : 'warn', '类 ' . $cls);
}
foreach (['LITELOG_ROOT','BLOG_URL','CONTENT_PATH','DB_PREFIX','TEMPLATE_URL'] as $c) {
diag_row(defined($c) ? 'ok' : 'err', '常量 ' . $c . (defined($c) ? ' = ' . htmlspecialchars((string)constant($c)) : ' 未定义'));
}
/* 阶段 3:插件状态 */
echo '<h2>阶段 3 · 插件状态</h2>';
$pluginName = 'my_plugin'; // ← 修改为你的插件名
$active = (string)Option::get('active_plugins', '');
diag_row(strpos(',' . $active . ',', ',' . $pluginName . ',') !== false ? 'ok' : 'err', $pluginName . ' 已启用');
diag_row('info', 'url_rewrite = ' . Option::get('url_rewrite', 'n'));
diag_row('info', 'plugin_rewrite = ' . Option::get('plugin_rewrite', 'y'));
diag_row('info', 'isMobileDevice() = ' . (function_exists('isMobileDevice') && isMobileDevice() ? 'YES' : 'NO'));
/* 阶段 4:视图语法检查 */
echo '<h2>阶段 4 · 视图语法检查</h2>';
$base = CONTENT_PATH . 'plugins/' . $pluginName . '/';
foreach (['views/main.php', 'views/detail.php', $pluginName . '.php'] as $rel) {
$path = $base . $rel;
if (!is_file($path)) { diag_row('err', $rel . ' 不存在'); continue; }
diag_row('ok', $rel . '(' . number_format(filesize($path)) . ' 字节)');
if (function_exists('exec')) {
$out = []; $ret = 0;
@exec('php -l ' . escapeshellarg($path) . ' 2>&1', $out, $ret);
diag_row($ret === 0 ? 'ok' : 'err', ' └ ' . ($ret === 0 ? '语法通过' : implode(' ', $out)));
}
}
/* 阶段 5:错误日志 */
echo '<h2>阶段 5 · 最近错误日志</h2>';
foreach (['content/backup/plugin-error.log', 'content/backup/hook-error.log', 'content/backup/route-error.log'] as $lf) {
$path = LITELOG_ROOT . $lf;
if (!is_file($path) || filesize($path) === 0) continue;
diag_row('info', $lf);
$lines = explode("\n", trim(@file_get_contents($path)));
echo '<pre>' . htmlspecialchars(implode("\n", array_slice($lines, -15))) . '</pre>';
}
echo '<h2>✅ 诊断完成</h2><p>请把整页内容截图发给我。</p><p>⚠️ 用完请删除 _diag_plugin.php</p>';
?>
</body></html>
关键点:
while (ob_get_level() > 0) @ob_end_clean();—— 先清空所有缓冲define('LITELOG_ROOT', DIR . DIRECTORY_SEPARATOR)—— 必须先定义,否则init.php会exit('Access Denied')$_GET['force_view'] = 'desktop'—— 避免 mobile router 提前 exit
14.4 三点式日志探针
适用:页面白屏但错误日志无具体信息,或错误被吞掉。
原理:在关键位置插入只写日志、不输出的探针:
@file_put_contents(
CONTENT_PATH . 'backup/probe.log',
'[' . date('Y-m-d H:i:s') . '] 标记 ①' . "\n",
FILE_APPEND | LOCK_EX
);
三点布设:
| 探针 | 位置 | 验证 |
|---|---|---|
| ① | {插件名}.php 主文件 define('XXX_HOOKS_REGISTERED', true) 之后 | 主文件是否加载 |
| ② | 控制器方法开头 | 控制器是否被调用 |
| ③ | 视图文件顶部 | 视图是否开始执行 |
结果解读:
| probe.log 内容 | 含义 | 下一步 |
|---|---|---|
| 文件不存在 | 请求没到插件 | dispatcher 路由没匹配 |
| 只有 ① | 主文件加载了,控制器没被调用 | dispatch_plugin_route 没触发 |
| 有 ① + ② | 控制器进入,视图没执行 | 控制器内部崩了 |
| 有 ① + ② + ③ | 视图开始执行,崩在视图内部 | 视图有语法或运行时错误 |
| 只有 ①,重复 20 次 | 插件被重复加载 | 有文件循环 require |
排错完成后删除所有探针和 probe.log。
14.5 通用验证手法
| 手法 | 用途 |
|---|---|
php -l | 语法检查 |
_diag_plugin.php | 综合诊断 |
| 三点式日志探针 | 白屏定位 |
var_dump($x); exit; | 短路调试 |
独立验证脚本(php _verify_xxx.php) | 复杂逻辑离线验证 |
grep -rn 'xxx' content/plugins/{插件名}/ | 全局搜索 |
| F12 Network | 前端请求 URL / 状态码 |
全角标点检查:
grep -rnE '\$[a-zA-Z_][a-zA-Z0-9_]*[():;,。!?]' content/plugins/{插件名}/
返回值类型速查:
| 调用 | 返回 | 陷阱 |
|---|---|---|
$DB->fetchOne(...) | array \| false | 不是 null |
$DB->fetchColumn(...) | 标量 \| false | 空结果 false |
$DB->insert(...) | string | 必须 (int) |
$DB->update/delete(...) | true | 不是行数 |
Option::get(...) | string | 永远字符串 |
Hook::applyFilters(...) | 回调返回值 | null 时保留原值 |
第 15 章 完整最小骨架
15.1 目录结构
content/plugins/my_plugin/
├── my_plugin.php
├── my_plugin_callback.php
├── my_plugin_setting.php
├── frontend/
│ └── routes.php
├── views/
│ └── main.php
└── assets/
├── style.css
└── script.js
15.2 my_plugin.php
<?php
/**
* Plugin Name: 我的插件
* Description: 一句话描述功能
* Version: 1.0.0
* Author: 你的名字
* Author URI: https://example.com
*/
if (!defined('LITELOG_ROOT')) exit('error!');
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'; }
}
/* ★ 通用 POST 按钮生成器(绝对 URL + CSRF) */
if (!function_exists('MP_postBtn')) {
function MP_postBtn($action, array $params, $label, $confirm = '', $class = '') {
$qs = http_build_query(array_merge(['action' => $action], $params));
$formAction = BLOG_URL . '?' . $qs;
$on = '';
if ($confirm !== '') {
$jsonConfirm = json_encode((string)$confirm,
JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP);
$on = ' onsubmit="return confirm(' . htmlspecialchars($jsonConfirm, ENT_QUOTES, 'UTF-8') . ')"';
}
$cls = $class !== '' ? ' class="' . htmlspecialchars($class, ENT_QUOTES, 'UTF-8') . '"' : '';
return '<form method="post" action="' . htmlspecialchars($formAction, ENT_QUOTES, 'UTF-8') . '" style="display:inline"' . $on . '>'
. (class_exists('Csrf') ? Csrf::field() : '')
. '<button type="submit"' . $cls . ' style="background:none;border:0;padding:0;cursor:pointer;color:inherit;font:inherit">'
. $label . '</button></form>';
}
}
/* Hook 注册 */
if (class_exists('Hook')) {
try {
Hook::addAction('layout_head_end', 'MP_injectCss');
Hook::addAction('template_head', 'MP_injectCss');
Hook::addAction('layout_footer_scripts', 'MP_injectJs');
Hook::addAction('body_end', 'MP_injectJs');
} 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
);
}
}
if (!function_exists('MP_injectCss')) {
function MP_injectCss() {
static $done = false;
if ($done) return;
$done = true;
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";
}
}
if (!function_exists('MP_injectJs')) {
function MP_injectJs() {
static $done = false;
if ($done) return;
$done = true;
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";
}
}
/* 加载路由文件 */
if (class_exists('PluginRegistry')) {
try {
$__routes = __DIR__ . '/frontend/routes.php';
if (is_file($__routes)) require_once $__routes;
} catch (Throwable $e) { /* 写日志 */ }
}
15.3 my_plugin_callback.php
见 §3.3。
15.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
);
}
15.5 views/main.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
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;
}
/* 三变量兜底 */
$page_title = '我的插件 - ' . Option::get('site_title', '');
$title = $page_title;
$__pageTitle = $page_title;
/* JS 强制覆盖 document.title */
if (empty($GLOBALS['__mp_title_hooked'])) {
$GLOBALS['__mp_title_hooked'] = true;
$__mp_title = $page_title;
if (class_exists('Hook')) {
try {
$__mp_js = '<script>(function(){var t='
. json_encode($__mp_title, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)
. ';if(document.title!==t){document.title=t;}})();</script>' . "\n";
Hook::addAction('layout_head_end', function () use ($__mp_js) {
if (empty($GLOBALS['__mp_active'])) return;
echo $__mp_js;
});
Hook::addAction('template_head', function () use ($__mp_js) {
if (empty($GLOBALS['__mp_active'])) return;
echo $__mp_js;
});
} catch (Throwable $e) {}
}
}
include View::resolve('header');
?>
<div class="mp-container">
<h1>我的插件</h1>
<form method="post" action="<?= BLOG_URL ?>?action=my_plugin_ajax">
<?= Csrf::field() ?>
<input type="text" name="name" placeholder="名称">
<button type="submit">提交</button>
</form>
</div>
<?php include View::resolve('footer'); ?>
15.6 my_plugin_setting.php
见 §6.3。
15.7 assets/style.css / assets/script.js
空文件也必须存在(否则 filemtime() 会 warning)。
第 16 章 交付前自检清单
16.1 结构
- [ ] 目录名 = 主文件名(小写下划线)
- [ ] 插件名不与真实目录 / 核心前缀冲突(§2.3)
- [ ] 有
{插件名}.php+{插件名}_callback.php - [ ] 有
frontend/routes.php(如需要) - [ ] 有
views/目录 - [ ] 有
assets/目录(哪怕空文件) - [ ] 有
{插件名}_setting.php(如需设置页)
16.2 安全
- [ ] 所有 SQL 用占位符
- [ ] 所有输出过
h() - [ ] 无文件操作,或过
basename() - [ ] AJAX 写操作有登录校验,且先登录后 CSRF
- [ ] 原子扣减用
query()->rowCount() - [ ] 无
unserialize/eval - [ ]
IN (...)用array_map('intval')+ 占位符 - [ ] 未覆盖 PDO 属性
- [ ] 所有 POST 表单有
Csrf::field() - [ ] 所有 AJAX 带
X-CSRF-Token - [ ] 后台入口调
Auth::requireCap() - [ ] 所有表单 action 是绝对 URL(§13.1)
16.3 ⭐ 500 免疫
- [ ] 主文件每个
define()有if (!defined(...)) - [ ] 主文件每个
function有if (!function_exists(...)) - [ ] 所有
Option::get()第二参数是'' - [ ]
callback_init()整段 try/catch,DDL 无COMMENT/COLLATE - [ ] 视图顶部有
function_exists()守卫 - [ ] 所有
Hook::addAction()包在class_exists('Hook')+ try/catch - [ ]
PluginRegistry调用前有class_exists()守卫 - [ ] 主文件顶层无副作用(无 DB / IO / HTTP / 重循环 /
Option::set) - [ ] 每个
.php文件都通过php -l
16.4 ⭐ 视图与资源
- [ ] 前台视图
include View::resolve('header')和footer - [ ] 前台视图设置三变量
$page_title/$title/$__pageTitle - [ ] 前台视图不自建
<!DOCTYPE html> - [ ] CSS/JS 走
layout_head_end/layout_footer_scriptsHook 注入 - [ ] 资源 URL 拼
BLOG_URL,带?v=filemtime - [ ] PHP 标签合并,避免连续两个
<?php(§9.5) - [ ] 钩子里用
h()而非$this->e()(§7.4)
16.5 ⭐ 输出缓冲
- [ ] 没有使用
ob_start('callback')做页面拦截 - [ ] 如果必须用输出缓冲,用
Output::capture() - [ ] 如果注册了
dispatch_end,逻辑里有长度守卫和非字符串输入处理
16.6 API 使用
- [ ]
$DB->fetchOne()判false - [ ]
$DB->insert()返回值(int)转换 - [ ] 需要行数用
query()->rowCount() - [ ] 用户名用
nickname ?: username - [ ] URL 拼接不重复加斜杠
- [ ] 函数/常量/表名有插件前缀
- [ ] AJAX 端点九条铁律全过(§7.1 的 AJAX 模板)
- [ ]
$_REQUEST换成显式$_POST/$_GET - [ ] 设置保存用
array_key_exists判缺失(§13.4)
16.7 ⭐ 性能
- [ ] 主文件顶层无查库 / IO / HTTP / 重循环
- [ ] 每个 Hook 回调执行 < 5ms,无 DB 查询、无远程请求
- [ ] 单次请求读 ≥ 3 个 option,用
Option::preload()批量预取 - [ ] 每请求都读的 option 设了
autoload='y',偶尔读的用默认'n' - [ ] 自定义 Widget 声明了
$cacheable,随机 / 时变内容设为false - [ ] Widget
render()内无 N+1 查询,循环查询改GROUP BY - [ ] 无
ORDER BY RAND(),改用采样法 - [ ] 无
SELECT *拉全表,明确列名 +LIMIT - [ ] 大表查询有分页 / 限行
- [ ] 循环内无 DB 查询,改用
IN批查 - [ ] 自定义表建了合理索引
- [ ] AJAX 写操作有限流,读操作有缓存
- [ ] 超过 3 秒的任务已队列化
- [ ] 关键位置加了
mp_perf_tick()探针,单次请求总和 < 30ms
16.8 ⭐ 部署卫生
- [ ] 每个
.php文件 UTF-8 无 BOM + LF 行尾 - [ ]
data/目录有.htaccess - [ ] 无
.bak/.tmp/.revert残留 - [ ] 全角标点紧跟
$var已检查({$var}形式) - [ ] 登录链接统一(建议带来源,但核心当前不支持
next) - [ ] 插件名不在禁用清单
- [ ] 卸载脚本可清理所有数据(表 / option / 缓存文件)
16.9 验证
- [ ] 启用 → 访问 → 停用 → 再启用 → 卸载 全流程无异常
- [ ] 浏览器标签页标题正确
- [ ] F12 Console 无报错,Network 全 200
- [ ] 卸载后表删除、option 清空、无残留
- [ ] 用
_diag_plugin.php走查一遍
第 17 章 三十八条铁律
| # | 教训 |
|---|---|
| 1 | 规范是地图,内核是地形。地图和地形冲突时,信地形 |
| 2 | 不确定的机制,先用 error_log / var_dump / 读源码验证,禁止靠猜 |
| 3 | 有可工作参考实例时,先"逐行结构对齐",再谈业务 |
| 4 | 导出/文档没看到某物 ≠ 某物不存在;先问,再断言 |
| 5 | 每条规范都有场景前提,问清上下文再套用 |
| 6 | 少说"一定";多说"请验证" / "如未生效,查这里" |
| 7 | "多键兜底""兼容写法"往往是懒惰的遮羞布 |
| 8 | 视图不能自建 HTML 骨架 |
| 9 | CSS/JS 走 Hook 注入 |
| 10 | 内核只自动加载主文件 + 约定路径的 register/routes;子文件要主动 require |
| 11 | 不要用 ob_start('callback') 做页面拦截 |
| 12 | ob_start 回调里返回 null 会清空整个页面 |
| 13 | 在 ob_start 回调里再 ob_start 是 PHP 明确禁止的 |
| 14 | 处理 HTML 输出时,必须有"长度守卫" |
| 15 | **优先用 layout_* 布局锚点做页面注入** |
| 16 | 每一个 Hook::addAction() 都包 try/catch |
| 17 | 用户报告的"XX 无效",先复现、先看日志,再排查 |
| 18 | 修复 bug 时,先加日志,再改代码 |
| 19 | 所有 POST 请求默认被 CSRF 拦截(插件页面/设置页除外) |
| 20 | 插件后台页面跳过核心全局 CSRF,需自行校验 |
| 21 | 后台操作前调 Auth::requireCap() |
| 22 | Option::get() 第二参数永远是 '' |
| 23 | 所有表单 action 必须是绝对 URL——action="?xxx" 在伪静态路径下会路由错乱 |
| 24 | JS 动态生成表单的 action 也必须是绝对 URL |
| 25 | 视图顶部同时设置 $page_title / $title / $__pageTitle——主题 header 变量名不可预测 |
| 26 | 主题 header 硬编码时,用 JS 强制覆盖 document.title |
| 27 | 连续两个 <?php 是陷阱——合并为一个大 PHP 块 |
| 28 | 诊断脚本必须先 define('LITELOG_ROOT', ...)——否则 init.php 会 exit('Access Denied') |
| 29 | 移动端 router.php 会抢跑并 exit——init.php 里它在 loadActivePlugins() 之前 |
| 30 | $_REQUEST 受 request_order 影响——显式用 $_POST / $_GET |
| 31 | 主文件顶层禁止任何副作用——查库、IO、HTTP、重循环必须移到 register/routes 或 controller |
| 32 | Hook 回调必须 O(1) 或 O(极少),单次 < 5ms——禁止在回调里做 IO、查库、远程请求 |
| 33 | 自定义 Widget 必须声明 $cacheable——随机/时变内容必须设为 false |
| 34 | 循环里禁止查询——N+1 必须改为 IN 批查或 GROUP BY |
| 35 | **禁止 SELECT 拉全表*——明确列名 + LIMIT 或分页 |
| 36 | 禁止 ORDER BY RAND()——万行以上会拖垮数据库,改用采样法 |
| 37 | 单次请求读 ≥ 3 个 option 必须用 Option::preload() 批量预取 |
| 38 | 超过 3 秒的任务必须队列化——前台只返回任务 ID,由 cron/CLI 消费 |
常见错误推理链
| ❌ 错误链 | ✅ 正确链 |
|---|---|
路由不生效 → "可能 PluginRegistry 不存在" → 改用 Hook → 引入新问题 | 读 dispatcher.php → 发现路由在核心匹配 → 检查自己的注册代码 → 修复 |
CSRF 报错 → "可能 Csrf 类没加载" → 加 class_exists → 还是错 | 看报错是"缺少令牌"还是"不匹配" → 缺就检查表单/AJAX 是否带 token;不匹配查 session |
| 删除按钮没反应 → "可能权限问题" → 加日志 → 还是没反应 | F12 Network → 看请求 URL → 如果是 /xxx/yyy?action=... 而非 /?action=... → 相对 URL 陷阱 |
标题不对 → 改 $page_title → 无效 → 改 $title → 无效 → 放弃 | 读主题 header.php → 看它读哪个变量 → 硬编码则用 JS 覆盖 |
| 白屏 → "可能数据库问题" → 检查 DB → 正常 → 清缓存 → 还白屏 | 用 _diag_plugin.php → 阶段 4 显示 syntax error → php -l 验证 → 修复 |
| 插件区块只显示一半 → 猜模板渲染出错 → 反复改模板 | 打开 plugin-error.log → 看到 Using $this → 钩子里用 h() 替代 $this->e() |
| 后台保存后字段变 0 → 猜数据库问题 → 查库正常 | 检查 (int)($_POST[$k] ?? 0) → 字段在某次表单没出现 → 改 array_key_exists 跳过 |
第 18 章 给 AI 的指令模板
18.1 从零开发新插件
请按 LiteLog 插件开发规范 v5.0 开发一个 XXX 插件:
【附件:LiteLog 插件开发规范 v5.0 完整版.md】
需求:
- 功能:……
- 前台入口:URL ……,action=……
- 后台入口(可选):菜单名「……」,action=……,设置页放 `{插件名}_setting.php`
- 数据表:……
严格遵守本文件所有条款,特别是:
- 第 1 章「三条硬性安全规则」+「九条最高级别红线」+「十条口诀」
- 第 2 章「目录结构与自动加载真相」+ §2.3「插件名约束」
- 第 3 章「主文件与生命周期」+ §3.4「顶层禁止副作用」
- 第 5 章「路由系统」
- 第 7 章「钩子系统全表」+ §7.4「钩子回调作用域」
- 第 9 章「视图与模板」(含 §9.2 三变量兜底 + §9.5 PHP 语法安全)
- 第 11 章「CSRF 与权限」(含 §11.5 先登录后 CSRF)
- 第 13 章「安全与陷阱」(含 §13.1 相对 URL + §13.4 设置字段缺失 + §13.5 上传白名单)
- 第 16 章「交付前自检清单」
不要修改任何核心文件。完成后给出完整文件列表和每个文件的完整代码。
18.2 排查 500 错误
我的插件启用后后台 500。请按规范第 1.3 节「十条口诀」排查。
【附件:LiteLog 插件开发规范 v5.0 完整版.md】
报错信息:……
18.3 排查 CSRF 错误
我的插件出现 CSRF 校验失败:……
【附件:LiteLog 插件开发规范 v5.0 完整版.md】
当前代码:(贴表单 / AJAX / 端点)
日志:(content/backup/plugin-error.log)
请按第 11 章排查。
18.4 排查"按钮点了没反应"
我的插件里有一个删除按钮,点击后页面刷新了但目标没被删除。
【附件:LiteLog 插件开发规范 v5.0 完整版.md】
当前表单代码:……
浏览器 Network 面板显示的请求 URL:……
请按第 13.1 节「相对 URL 陷阱」排查。
18.5 排查"标题不对"
我的插件页面浏览器标签始终显示站点名,不是插件标题。
【附件:LiteLog 插件开发规范 v5.0 完整版.md】
主题 header.php 的 <title> 那行代码:……
插件视图顶部代码:……
请按第 9.2 节排查。
18.6 排查"插件区块只显示一半"
我的插件页面/区块只渲染了一半,前半截 HTML 正常,后半截消失,HTTP 仍是 200,浏览器控制台干净。
【附件:LiteLog 插件开发规范 v5.0 完整版.md】
当前代码:……(贴钩子文件 / 视图文件)
请按第 7.4 节「钩子回调作用域与 $this」排查:
1. 是否在钩子回调里用了 $this->e() / $this->getData() / $this->icon()?
2. 查 content/backup/plugin-error.log 有没有 "Using $this when not in object context"?
18.7 排查"后台设置保存后字段变成 0"
后台设置页点保存后,提示保存成功,但某个字段变成了 0 / 空字符串。
【附件:LiteLog 插件开发规范 v5.0 完整版.md】
当前保存代码:……
表单字段:……(特别是条件显示的部分)
请按第 13.4 节「后台设置保存:字段缺失跳过」排查:
1. 是否用了 (int)($_POST[$k] ?? 0) 这种把"缺失"当"0"的写法?
2. 该字段是否在某些条件下没出现在表单里?
3. 改成 array_key_exists($k, $_POST) 跳过缺失字段。
18.8 排错通用模板
我的插件出现以下症状:……
【附件:LiteLog 插件开发规范 v5.0 完整版.md】
当前代码:(贴主文件 + 视图 + 路由 + 设置页)
内核相关信息:(贴 dispatcher.php / 主题 header.php)
日志:(content/backup/plugin-error.log)
请按以下顺序排查:
1. 先读内核源码确认机制(不要猜)
2. 再检查代码是否符合机制
3. 给出最小修改方案
4. 给出验证方法
如果需要诊断脚本,请直接给我一个完整的 `_diag_plugin.php`。
第 19 章 附录
附录 A · API 速查
Database
$DB->query($sql, $params) // PDOStatement
$DB->fetchAll($sql, $params) // array
$DB->fetchOne($sql, $params) // array | false
$DB->fetchColumn($sql, $params) // scalar | false
$DB->insert($table, $data) // string lastInsertId
$DB->update($table, $data, $where, $params) // true
$DB->delete($table, $where, $params) // true
$DB->pdo() // PDO
$DB->prefix() // 表前缀
Option
Option::get($key, '') // string(第二参数永远 '')
Option::set($key, $value) // void
Option::set($key, $value, 'y') // 强制 autoload
Option::preload(['k1','k2']) // 批量预取
Option::all() // array
Hook
Hook::addAction($hook, $func, $priority = 10)
Hook::doAction($hook, ...$args)
Hook::removeAction($hook, $func = null)
Hook::hasAction($hook)
Hook::captureAction($hook, ...$args)
Hook::applyFilters($hook, $value, ...$args)
View
View::resolve($name) // file path(移动端优先)
View::display($name, $data = [])
Url
Url::log($gid, $alias)
Url::sort($sid, $alias)
Url::tag($name)
Url::author($uid)
Url::record($date)
Url::isRewriteOn() // 核心伪静态开关
Url::isPluginRewriteOn() // 插件伪静态开关
Csrf
Csrf::token() // string
Csrf::field() // HTML input
Csrf::verify() // void(失败 403)
Csrf::verifyJson($payload)
Auth
Auth::check() / Auth::id() / Auth::role()
Auth::can($cap) / Auth::requireCap($cap)
Auth::canEditLog($log)
Auth::regenerate()
PluginRegistry
PluginRegistry::addAdminPage($action, $config)
PluginRegistry::addAdminMenu($menu)
PluginRegistry::addFrontendRoute($regex, $cb)
PluginRegistry::addAdminAsset($action, $type, $url)
PluginRegistry::enqueueAsset($type, $url)
Output
Output::capture($callable, ...$args)
Output::start($name) / Output::end($name)
Output::wrap($name, $callable, ...$args)
Output::discard($name) / Output::level()
辅助函数
h($s) // HTML 转义
redirect($url) // 跳转 + exit
isLoggedIn() // 是否登录
checkLogin() // 强制登录(未登录跳后台登录页)
dateFmt($ts) / datetimeFmt($ts)
getExcerpt($html, $len)
makeAlias($str)
pagination($page, $total, $perPage)
getPageNumber()
附录 B · 命名约定
| 符号 | 命名 | 示例 |
|---|---|---|
| 函数 | {前缀}_{CamelCase} | ICG_isEnabled() |
| 常量 | {前缀}_UPPER | ICG_VER |
| 类 | {PrefixCamel} / {Prefix_Camel} | Widget_IconGen |
| 表名 | {DB_PREFIX}{插件名}_{表名} | lt_icon_generator_items |
| Option 键 | {插件名}_{键名} | icon_generator_enabled |
| 钩子 | {插件名}_{事件} | my_plugin_after_save |
核心已占用前缀:Forum_* / UserGroup_* / Uc_* / Widget_* / HomepageMode_* / DIY_* / Output / Csrf / Auth
路径常量:
| 常量 | 结尾斜杠 |
|---|---|
LITELOG_ROOT | ✅ |
CONTENT_PATH | ✅ |
ADMIN_PATH | ✅ |
INCLUDE_PATH | ✅ |
BLOG_URL | ✅ |
DB_PREFIX | ❌ |
附录 C · 案例集
案例 D.1:相对 URL 陷阱导致删除按钮失效
- 症状:论坛插件点击"删除主题"按钮,页面刷新但帖子还在。
- 排查:F12 Network → 请求 URL 是
POST /forum/thread/5?action=forum_delete&id=5→ 表单action="?action=forum_delete&id=5"是相对 URL。 - 修复:改为
action="<?= BLOG_URL ?>?action=forum_delete&id=5"。 - 教训:见 §13.1。
案例 D.2:标题不显示插件名
- 症状:访问
/daily_article/detail/5,浏览器标签显示站点名。 - 排查:主题
header.php读$page_title(已设置);用诊断脚本发现View::resolve('header')返回mobile/header.php(Cookielitelog_view=mobile);移动端 header 读$__pageTitle(未设置)。 - 修复:三变量都设置 + JS 覆盖
document.title。 - 教训:见 §9.2。
案例 D.3:视图白屏无错误
- 症状:访问
/daily_article/detail/5页面完全空白。 - 排查:用诊断脚本 → 阶段 4 显示
syntax error on line 17→ 打开文件发现连续两个<?php。 - 修复:合并为一个 PHP 块。
- 教训:见 §9.5。
案例 D.4:插件路由 404
- 症状:访问
/daily_article/detail/5显示博客首页列表。 - 排查:读
dispatcher.php确认 v2.1 已内置matchPluginUri(),但当前plugin_rewrite开关关闭,或插件主文件不存在。 - 修复:开启
plugin_rewrite或检查插件主文件。 - 教训:见 §5.2。
案例 D.5:AJAX 403 "缺少 CSRF 令牌"
- 症状:后台发文章返回 403。
- 排查:
admin/index.php对所有 POST 强制Csrf::verify();插件表单漏加Csrf::field()。 - 修复:所有 POST 表单加
<?= Csrf::field() ?>。 - 教训:见 §11.2。
案例 D.6:插件区块只显示一半(v3.4 新增)
- 症状:钩子渲染的区块前半截正常,后半截消失,HTTP 200。
- 排查:查
plugin-error.log看到Using $this when not in object context;钩子里用了$this->e()。 - 修复:用
h()替代。 - 教训:见 §7.4。
附录 D · 更新日志
v5.0(2026-10-18)——合并版
- 合并 v3.4 规范与 v4.0 手册
- 与 LiteLog 内核 v2.1 源码逐行核对
- 修正 v3.3「罪 7」:
admin/register.php和frontend/routes.php放在约定路径时内核会自动加载 - 修正 v3.4 §2.7:核心当前不支持
next参数,登录回跳改为"先登录后 CSRF"策略 - 新增 §2.3「插件名约束」、§7.4「钩子回调作用域」、§13.4「设置字段缺失」、§13.5「上传白名单」
- 结构参考 FlintHub:总则 → 目录 → 路由 → 钩子 → 数据库 → 视图 → 资源 → CSRF → 安全 → 调试 → 骨架 → 自检 → 铁律 → 指令 → 附录
- 精简合并:代码模板保留最关键部分,钩子表按触发点核对
v3.4(2026-10-18):新增 10 条(主文件顶层副作用 / 插件名约束 / 登录回跳 / 钩子作用域 / 设置字段缺失 / 上传白名单 / 通用验证 / 部署卫生 / 曝光统计 / 错误信息可诊断)
v4.0(2026-10-13):手册结构重构为 8 部分,铁律从 22 条扩到 30 条,加入相对 URL 陷阱、三变量兜底、三点式探针、诊断脚本、常见错误诊断表、5 个真实案例集、API 速查表。
v3.3(2026-10-17):新增第 21 章性能规范,铁律从 30 条扩到 38 条,Widget $cacheable,Option::preload(),长任务队列化。
v3.2(2026-10-13):相对 URL 陷阱、三变量兜底、伪静态路由补丁、PHP 标签合并、诊断脚本、三点式探针、移动端 router 抢跑、$_REQUEST 显式化。
收尾
一句话总结:
「define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require,PHP 标签别写乱,顶层副作用莫要犯。」
核心原则 18 条:
- 视图 include 主题 header/footer
- 资源走
layout_*Hook 注入 - 不要用
ob_start('callback')拦截页面 - 必须用输出缓冲时,用
Output::capture()+ 长度守卫 - 机制不明,先读源码
- 所有 POST 表单加
Csrf::field(),AJAX 带X-CSRF-Token - 后台操作前调
Auth::requireCap() - 插件设置页放
{插件名}_setting.php - 所有表单 action 必须是绝对 URL
- 标题三变量兜底 + 必要时 JS 覆盖
document.title - PHP 标签合并,避免连续两个
<?php - 诊断脚本必须先
define('LITELOG_ROOT') - 三点式日志探针法定位白屏崩在哪一步
- 核心 dispatcher 内置伪静态路由识别(v2.1 已支持)
- 移动端 router 会抢跑并
exit——在loadActivePlugins()之前 $_REQUEST换成显式$_POST/$_GET- 主文件顶层禁止副作用——查库/IO/HTTP/重循环移到 register/routes/controller
- 钩子回调内
$this不可用——用h()替代$this->e()
掌握这 19 章 + 38 条铁律 + 5 条核心原则 + 3 条安全规则 + 9 条红线,99% 的插件能一次跑通。
本文档基于 LiteLog 内核 v2.1 + default 主题 v1.3.1 + 每日一文 v1.2.7 修复战整理,合并 v3.4 规范与 v4.0 手册,并与核心源码逐行核对(2026-10-18)。