LiteLog 插件开发规范 v3.3
版本:3.3(2026-10-17)
性质:在 v3.2 基础上,新增第 21 章《性能规范》与第 19 章铁律 31–38 条,把"插件多时拖慢网站"从经验层面正式纳入规范约束。
核实基准:LiteLog 内核 v2.1 + default 主题 v1.3.1 + 每日一文 v1.2.7 + 性能改造 v2.1
v3.3 相对 v3.2 的核心新增:
- ① 第 21 章《性能规范》 —— 主文件加载、Hook 回调、Widget、数据库、AJAX、长任务、缓存策略、性能自检的完整性能预算与禁止清单(第 21 章)
- ② 第 19 章铁律从 30 条扩至 38 条 —— 新增 8 条性能红线(21.7)
- ③ 第 18 章自检清单新增性能项 —— 交付前必须逐项确认(18.7)
- ④ 第 12.1 节自定义 Widget 规范更新 —— 必须声明
$cacheable(12.1) - ⑤ 第 4.2 节自动加载行为补充 —— Widget 内置类改为惰性加载(4.2)
📌 给 AI 的必读(0 号指令)
写代码前,请按顺序读完以下章节:
- 第 1 章《500 免疫》——违反任意一条,后台直接 500。
- 第 2 章《五条硬性安全规则》——★ v3.2 从四条扩为五条,新增"绝对 URL"。
- 第 3 章《九条最高级别红线》——一票否决。
- 第 4 章《目录结构与自动加载真相》。
- ★ 第 5.3 节《PHP 语法安全写法》——防 Parse error。
- ★ 第 7.6 节《伪静态路由识别》——/插件名/xxx 必须的核心补丁。
- ★ 第 8.5 节《相对 URL 陷阱》——v3.2 最重要新增。
- 第 8.6 节《输出缓冲与响应拦截》——ob_start 陷阱。
- 第 10 章《视图与模板》——必须 include 主题 header/footer;评论表单必须加 CSRF;标题必须三变量兜底。
- 第 11 章《资源加载》——CSS/JS 走 layout_* Hook 注入。
- ★ 第 14.4 节《AJAX 端点九条铁律》。
- ★ 第 16.5–16.6 节《诊断脚本 + 三点式日志探针》——排错必备。
- ★ 第 21 章《性能规范》——★ v3.3 新增,插件多时的速度与内存预算。违反任意一条,插件上线后可能拖慢网站。
目录
- 第 1 章:500 错误免疫
- 第 2 章:五条硬性安全规则
- 2.4 CSRF 保护
- 2.5 后台权限系统
- 2.6 绝对 URL 规则
- 第 3 章:九条最高级别红线
- 第 4 章:目录结构与自动加载真相
- 第 5 章:插件主文件与头注释
- 5.3 PHP 语法安全写法
- 第 6 章:生命周期
- 第 7 章:PluginRegistry 注册表
- 7.6 伪静态路由识别
- 第 8 章:钩子系统全览
- 8.5 相对 URL 陷阱
- 8.6 输出缓冲与响应拦截
- 第 9 章:数据库与模型
- 第 10 章:视图与模板
- 10.7 页面标题三变量兜底
- 第 11 章:资源加载
- 第 12 章:小工具与首页模式扩展
- 第 13 章:加载时机 / exit 边界 / 命名约定
- 13.6 移动端 router 会抢跑
- 第 14 章:API 参考
- 第 15 章:常见坑清单
- 第 16 章:调试方法
- 16.5 诊断脚本规范
- 16.6 三点式日志探针法
- 第 17 章:完整最小骨架
- 第 18 章:交付前自检清单
- 第 19 章:开发经验教训(38 条铁律)
- 第 20 章:给 AI 的指令模板
- ★ 第 21 章:性能规范(v3.3 新增)
第 1 章:500 错误免疫(最高优先级)
一旦发生,整个后台 500。违反任意一条,插件即不合格。
1.1 八宗罪
罪 1 ★★★★★:主文件顶层 define() / function 无守卫
// ❌
define('ICG_VER', '1.0.0');
function ICG_isEnabled() { /* ... */ }
// ✅
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
$v = Option::get('xxx_enabled', ''); // ★ 永远用 ''
罪 3 ★★★★:生命周期 DDL 用 COMMENT / COLLATE / 无 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
);
}
}
关键三条:① 整段 try/catch;② DDL 无 COMMENT;③ 无 COLLATE。
罪 4 ★★★★:视图直接调用插件函数,无 function_exists 守卫
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!function_exists('ICG_isEnabled')) {
echo '<div class="error">插件未正确加载</div>';
return;
}
罪 5 ★★★:Hook::addAction() 无 try/catch
罪 6 ★★★:PluginRegistry 调用无 class_exists 守卫
罪 7 ★★★:frontend/routes.php / admin/register.php 被主文件遗忘
内核 loadActivePlugins() 只加载主文件,子文件必须主文件主动 require。
★ 罪 8(v3.2 新增):PHP 标签混乱导致 Parse error
这是"每日一文"插件白屏的直接原因:视图文件里出现"连续两个 <?php"或"编辑器粘贴导致 <?php 被转义",会引发 syntax error, unexpected '<', expecting end of file。
详见第 5.3 节。
1.2 九条口诀汇总
「define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require,PHP 标签别写乱。」
第 2 章:五条硬性安全规则(v3.2 扩充)
规则 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>'; // ❌
2.4 CSRF 保护
内核从 v1.2 起,所有后台 POST 请求、前台评论提交都强制校验 CSRF token,插件必须配合。
2.4.1 核心 API
$token = Csrf::token(); // 获取当前会话 token
echo Csrf::field(); // 输出隐藏字段
Csrf::verify(); // 校验(失败直接 403 并输出,不返回)
Csrf::verifyJson($payload); // 从 JSON body 校验(AJAX 用)
2.4.2 表单必须加 CSRF token
任何 POST 表单必须:
<form method="post" action="...">
<?php if (class_exists('Csrf')) echo Csrf::field(); ?>
<!-- 其他字段 -->
</form>
实战教训:本轮"每日一文"插件后台 6 个表单全部漏加 Csrf::field(),导致发文章 403。核心会在 admin/index.php 里对所有 POST 强制 Csrf::verify(),除非该 action 是通过 PluginRegistry::addAdminPage() 注册的插件页(由插件自行校验)。
2.4.3 AJAX 必须带 X-CSRF-Token
后台 layout.php 已全局包装 window.fetch,同源请求自动加 header。但如果用 XMLHttpRequest 或跨域请求,需要手动加:
fetch(url, {
method: 'POST',
headers: {
'X-CSRF-Token': window.LITELOG_CSRF || '',
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
window.LITELOG_CSRF 由 layout.php 注入,所有后台页面都可以用。
2.4.4 插件 AJAX 端点的 CSRF 处理
插件自己的 ?action=xxx 端点如果走核心 public_action_xxx 钩子,不受全局 CSRF 拦截。但推荐插件也校验:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (class_exists('Csrf')) {
Csrf::verify();
}
}
插件后台页面(通过 PluginRegistry::addAdminPage() 注册)跳过了核心全局 CSRF 拦截,由插件自行决定:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (class_exists('Csrf')) Csrf::verify();
}
2.4.5 前台评论表单必须加 CSRF
这是主题改造最容易漏掉的。评论表单:
<form method="post" action="<?= BLOG_URL ?>?action=comment_post">
<?= Csrf::field() ?>
<input type="hidden" name="gid" value="<?= (int)$log['gid'] ?>">
<!-- ... -->
</form>
实战教训:默认主题的 echo_log.php 里评论表单必须有 Csrf::field(),否则所有评论提交都会返回 {"ok":false,"msg":"缺少 CSRF 令牌"}。
2.4.6 CSRF 失败时的响应格式
Csrf::fail() 会自动判断请求类型:
| 请求类型 | 响应 |
|---|---|
AJAX(X-Requested-With / Accept: application/json / JSON body) | {"ok":false,"msg":"..."} |
| 普通表单 | HTML 403 页面 |
插件不需要特殊处理。
2.5 后台权限系统
内核 v1.2 引入 Auth 类,取代裸 isLoggedIn()。插件后台入口必须做权限校验。
2.5.1 Auth API
Auth::check(); // 是否登录
Auth::id(); // 当前用户 uid
Auth::role(); // admin / editor / author / guest
Auth::can($cap); // 是否有某能力
Auth::requireCap($cap); // 强制校验,失败终止
Auth::canEditLog($log); // 文章所有者检查
Auth::regenerate(); // 会话再生成
Auth::loginLocked(); // 登录失败是否锁定
2.5.2 角色能力表
| 角色 | 能力 |
|---|---|
| admin | *(全部) |
| editor | log. / comment. / sort. / tag. / widget.manage / theme.manage / plugin.manage |
| author | log.own / comment.reply |
2.5.3 插件后台入口的权限写法
if (class_exists('PluginRegistry')) {
try {
PluginRegistry::addAdminPage('my_plugin', [
'title' => '我的插件',
'callback' => function () {
if (class_exists('Auth')) Auth::requireCap('plugin.manage');
include __DIR__ . '/pages/main.php';
},
]);
} catch (Throwable $e) { /* 写日志 */ }
}
2.5.4 前台登录校验
前台不需要 Auth,用 isLoggedIn() 即可。
2.6 绝对 URL 规则(★ v3.2 新增)
这是本轮修复战最重要的新增。请务必理解。
2.6.1 症状
论坛插件里有一个"删除帖子"按钮:
<form method="post" action="?action=forum_delete&id=5">
在 URL 为 /forum/thread/5 的页面上,浏览器会把 ?action=... 解析为:
http://li.zgzjw.top/forum/thread/5?action=forum_delete&id=5
后果:
- 核心 dispatcher 拿到的
$uri = 'forum/thread/5'(路径部分不含 query) $_GET['action'] = 'forum_delete'- 但某些 Nginx 重写规则会把这个 URL 当成"查看帖子 5",先执行了查看帖子的路由处理器,
exit掉了,导致删除请求根本没走到public_action_forum_delete - 更常见的坑:即使服务器没重写,
action="?xxx"在/forum/thread/5页面下实际提交到当前路径,如果当前路径被任何中间件拦截,一样会失败。
2.6.2 铁律
所有表单的 action 必须使用绝对 URL:BLOG_URL . '?...' 或 forum_pretty_url(...)。
2.6.3 正例 / 反例
<!-- ❌ 反例:相对 URL -->
<form method="post" action="?action=forum_delete&id=5">
<!-- ❌ 反例:空 action,会提交到当前 URL -->
<form method="post">
<!-- ✅ 正例:绝对 URL -->
<form method="post" action="<?= BLOG_URL ?>?action=forum_delete&id=5">
<!-- ✅ 正例:使用插件的 URL 生成函数 -->
<form method="post" action="<?= forum_pretty_url('delete/5') ?>">
2.6.4 JS 动态生成表单同样适用
// ❌ 反例
form.action = '?action=forum_thread_mod';
// ✅ 正例
form.action = '<?= BLOG_URL ?>?action=forum_thread_mod';
2.6.5 通用 POST 按钮生成器
if (!function_exists('my_post_btn')) {
function my_post_btn($action, array $params, $label, $confirm = '', $class = '') {
$qs = http_build_query(array_merge(['action' => $action], $params));
$formAction = BLOG_URL . '?' . $qs; // ★ 绝对 URL
$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="' . h($class) . '"' : '';
return '<form method="post" action="' . h($formAction) . '" 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>';
}
}
第 3 章:九条最高级别红线
- 严禁反序列化与动态执行——禁
unserialize()/eval()/assert()/call_user_func($_GET[...]) - 严禁滥用绝对路径——只用
LITELOG_ROOT/CONTENT_PATH/ADMIN_PATH/INCLUDE_PATH/DIR - 渲染钩子内禁止中断页面——禁
die()/exit()/header();必须return - 上传文件必须白名单 + 随机文件名——校验
getimagesize(),名用bin2hex(random_bytes(8)) - 数量扣减必须原子更新——
UPDATE ... WHERE stock>0+rowCount() IN (...)必须强转 + 占位符——array_map('intval')+?- PDO 属性不得覆盖
- AJAX 写操作必须有登录校验
- ★ v3.2 新增:所有表单 / AJAX 的 URL 必须是绝对路径——
BLOG_URL . '?...',禁止?action=xxx相对形式
第 4 章:目录结构与自动加载真相
4.1 目录结构
content/plugins/{插件名}/
├── {插件名}.php ← 【必须】主文件
├── {插件名}_callback.php ← 【可选】生命周期
├── {插件名}_setting.php ← 【可选】设置页
├── admin/
│ ├── register.php ← 【可选】后台注册
│ ├── pages/ ← 后台视图
│ └── assets/ ← 后台资源
├── frontend/
│ └── routes.php ← 【可选】前台路由
├── views/ ← 前台视图
├── assets/ ← 静态资源
└── data/ ← 私有数据(需 .htaccess)
4.2 内核真实自动加载行为
| 文件 | 自动加载? | 需要做什么 |
|---|---|---|
{插件名}.php | ✅ 是 | 无 |
{插件名}_callback.php | ❌ 否 | 内核在启用 / 停用时主动 require |
{插件名}_setting.php | ❌ 否 | 通过 ?action=plugin_setting&name=xxx 访问 |
frontend/routes.php | ❌ 否 | 主文件必须 require_once |
admin/register.php | ❌ 否 | 主文件必须 require_once |
views/*.php | ❌ 否 | 路由回调 / 控制器主动 include |
assets/* | ❌ 否 | Hook 或视图引用 |
★ v3.3 补充:内核 include/lib/function.common.php 在 v2.1 起对 widget_builtin.php / widget_extra.php / forum_homepage.php 做了按上下文惰性加载:
- 前台 / CLI:全部加载(与旧版相同)
- 后台
?action=widgets:加载 widget 文件(因为要渲染表单),不加载forum_homepage.php - 其它后台页:不加载上述文件;
WidgetRegistry::all()/get()/exists()首次调用时自动触发ensureBuiltinLoaded()兜底
对插件的影响:插件的 admin/register.php 如果依赖 Widget_XXX 类存在,需要显式调用 WidgetRegistry::ensureBuiltinLoaded()(或改用 WidgetRegistry::all() 之类的 API),否则后台其它页面看不到这些类。前台不受影响。
第 5 章:插件主文件与头注释
5.1 标准头注释
<?php
/**
* Plugin Name: 我的插件
* Description: 一句话描述
* Version: 1.0.0
* Author: 你的名字
* Author URI: https://example.com
*/
if (!defined('LITELOG_ROOT')) exit('error!');
5.2 主文件只做四件事
- 常量守卫(
define+function) - 定义工具函数
- 注册 Hook(
Hook::addAction) require子文件
禁止:顶层 echo / print / header / DB 写入 / 耗时 IO。
5.3 PHP 语法安全写法(★ v3.2 新增)
本轮修复战的直接教训:detail.php 第 17 行 syntax error, unexpected '<', expecting end of file,导致整页白屏。
5.3.1 危险写法:连续两个 <?php
<?php if (!defined('LITELOG_ROOT')) exit('error!'); ?> ← 单行 PHP 块
<?php ← 又开一个
/* 大量代码 */
问题:只要用户在编辑器里粘贴时第二个 <?php 丢失,或者被转义成 <?php,整个文件就会语法错乱。
实测:用户很可能从 Markdown 或富文本编辑器里复制粘贴,其中 <?php 已经被转义或损坏。
5.3.2 安全写法:合并为一个大 PHP 块
<?php
/**
* 每日一文 · 文章详情页
*/
if (!defined('LITELOG_ROOT')) exit('error!');
/* 所有 PHP 逻辑都在这一个块里 */
$page_title = '...';
$title = $page_title;
include View::resolve('header');
?>
<!-- 从这里开始输出 HTML -->
<div class="...">
...
</div>
<?php include View::resolve('footer'); ?>
只有必须输出 HTML 时才闭合 ?>,绝不出现"两个连续 PHP 块"的写法。
5.3.3 视图文件推荐骨架
<?php
/**
* 插件名 · 视图名
*/
if (!defined('LITELOG_ROOT')) exit('error!');
/* 1. 前置守卫 */
if (!function_exists('MYPLUGIN_isEnabled')) {
echo '<div style="padding:40px;text-align:center;color:#b91c1c">插件未加载</div>';
return;
}
/* 2. 所有变量准备 */
$page_title = '...';
$title = $page_title;
$__pageTitle = $page_title;
/* 3. 数据准备 */
$data = ...;
/* 4. 输出 HTML */
include View::resolve('header');
?>
<div class="my-page">
...
</div>
<?php include View::resolve('footer'); ?>
5.3.4 如何验证语法正确
php -l content/plugins/your_plugin/views/detail.php
# 期望:No syntax errors detected
没有 SSH:把文件内容复制到 https://3v4l.org/ 或 https://phpcodechecker.com/ 验证。
第 6 章:生命周期(callback 回调)
6.1 内核实测行为
| 时机 | 内核调用 | 你的回调 |
|---|---|---|
| 启用插件 | callback_init() | {插件名}_callback.php |
| 停用插件 | callback_rm() | 同上 |
| 升级插件 | callback_up()(需手动触发) | 同上 |
6.2 模板
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
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 $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_items`"); } catch (Throwable $e) {}
try { $DB->delete('option', "option_name LIKE 'my_plugin_%'"); } catch (Throwable $e) {}
}
第 7 章:PluginRegistry 注册表
7.1 完整 API
| 方法 | 签名 | 用途 |
|---|---|---|
addAdminPage | (string $action, array $config) | 注册后台页面 |
hasAdminPage | (string $action): bool | 是否存在 |
getAdminPage | (string $action): ?array | 获取配置 |
addAdminMenu | (array $menu) | 注册侧边栏菜单 |
getAdminMenus | (): array | 获取所有菜单 |
addFrontendRoute | (string $regex, callable $cb) | 注册前台路由 |
matchFrontendRoute | (string $uri): ?array | 匹配前台路由 |
addAdminAsset | (string $action, string $type, string $url) | 注册后台资源 |
enqueueAsset | (string $type, string $url) | 手动暂存资源 |
getPendingAssets | (string $type): array | 读取暂存资源 |
renderAdminPage | (string $action) | 渲染后台页面 |
renderPluginSetting | (string $name) | 渲染 {插件名}_setting.php,自动鉴权 |
7.2 插件设置页统一入口
设置页放在 {插件名}_setting.php,通过核心入口访问:
<?= BLOG_URL ?>admin/?action=plugin_setting&name=xxx
内核做的事:① 校验插件名合法;② 强制 Auth::requireCap('plugin.manage');③ 渲染页面;④ 自动包上 layout。
后台插件卡片会自动显示「设置」按钮。
7.3 前台路由注册
// frontend/routes.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;
try {
PluginRegistry::addFrontendRoute('icon/?', function ($matches) {
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');
return;
}
$GLOBALS['__icg_active'] = true;
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);
}
★ v3.3 补充:内核 v2.1 的PluginRegistry::addFrontendRoute()会按正则开头的静态首段分桶索引(如^forum/→'forum'桶),matchFrontendRoute()优先精确桶后通配桶,把匹配从 O(N) 压到 O(1)。插件仍按原签名调用,无需改动。
7.4 后台菜单注册
// 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
7.6 伪静态路由识别(★ v3.2 新增)
本轮修复战的核心发现:核心 dispatcher 不认识 /插件名/xxx 形式的 URL。默认会 fallback 到 LogController::display(),显示博客首页列表。
7.6.1 症状
- 访问
/daily_article/detail/5,显示的是博客首页列表,不是插件详情 - 浏览器标签显示站点名,不是插件标题
- 插件里挂的
template_head/body_end钩子不生效
7.6.2 两种解决思路
方案 A(推荐 · 不改核心):在插件主文件里挂 dispatch_start 钩子,主动把 URI 转换成 __plugin_path:
Hook::addAction('dispatch_start', function() {
if (!empty($_GET['__plugin_path'])) return;
if (isset($_GET['action'])) return;
$uri = strtok($_SERVER['REQUEST_URI'] ?? '/', '?');
$base = parse_url(BLOG_URL, PHP_URL_PATH) ?: '/';
if ($base !== '/' && $base !== '' && strpos($uri, $base) === 0) {
$uri = substr($uri, strlen($base));
}
$uri = trim($uri, '/');
if (preg_match('#^daily_article(?:/(.*))?$#i', $uri, $m)) {
$_GET['__plugin_path'] = 'daily_article';
$_GET['__plugin_rest'] = isset($m[1]) ? trim($m[1], '/') : '';
}
});
⚠️ 注意:dispatch_start钩子时机可能晚于mobile/router.php(后者在init.php里抢先加载),如果命中exit,这个钩子根本不会执行。所以此方案在移动端不完全可靠。
方案 B(彻底 · 改核心 dispatcher):让核心主动识别 /插件名/xxx。
在 include/lib/dispatcher.php 的 dispatch() 方法开头,Hook::doAction('dispatch_start'); 之后,插入:
/* ============================================================
* ★ 自动识别 /插件名/xxx 形式 → 转为 __plugin_path
* ============================================================ */
$uri = strtok($_SERVER['REQUEST_URI'] ?? '/', '?');
$base = parse_url(BLOG_URL, PHP_URL_PATH) ?: '/';
if ($base !== '/' && $base !== '' && strpos($uri, $base) === 0) {
$uri = substr($uri, strlen($base));
}
$uri = trim($uri, '/');
if (empty($_GET['__plugin_path']) && !isset($_GET['action']) && $uri !== '') {
// 排除核心已识别的 URL 前缀
$corePrefixes = ['post/', 'sort/', 'tag/', 'author/', 'record/', 'page/'];
$isCore = false;
foreach ($corePrefixes as $p) {
if (strpos($uri, $p) === 0) { $isCore = true; break; }
}
if (!$isCore && preg_match('#^([a-zA-Z0-9_-]+)(?:/(.*))?$#', $uri, $m)) {
$pluginName = $m[1];
$pluginRest = isset($m[2]) ? trim($m[2], '/') : '';
$mainFile = CONTENT_PATH . 'plugins/' . $pluginName . '/' . $pluginName . '.php';
if (is_file($mainFile)) {
$_GET['__plugin_path'] = $pluginName;
$_GET['__plugin_rest'] = $pluginRest;
}
}
}
优点:
- 所有走
dispatch_plugin_route钩子的插件自动获得伪静态支持 - 时机早于任何
exit,不会被 mobile router 抢跑 - 生命周期完整(
dispatch_end正常触发)
风险:
- 修改了核心文件,插件开发者需知会站点管理员
- 一个插件目录名如果和某 URL 前缀同名(如某插件叫
post),会被核心前缀排除规则挡掉
7.6.3 插件端配合(无论用哪个方案)
插件的路由分发钩子里,按 $rest 二次分发:
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;
});
注意:每个分发分支最后必须 exit 或 return,且 $GLOBALS['__plugin_routed'] = true 要在最前面设置。
第 8 章:钩子系统全览
8.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第一个参数是值,返回值替换;推荐新代码用 filter
8.2 生命周期钩子
| 钩子名 | 触发点 | 参数 |
|---|---|---|
plugin_activate | 插件启用后 | $name |
plugin_deactivate | 插件停用后 | $name |
plugin_uninstall | 插件卸载 | $name |
8.3 前台模板钩子
**主题布局锚点 layout_***
| 钩子名 | 触发点 | 推荐用途 |
|---|---|---|
layout_head_start | <head> 最前 | 早期 meta / preload |
layout_head_end | </head> 前 | 加载 CSS、注入 <link> |
layout_body_start | <body> 之后、<header> 之前 | 顶部横幅 / 公告条 |
layout_content_start | <main> 之后、内容前 | 面包屑 / 页面标题栏 |
layout_content_end | 内容后、</main> 前 | 相关文章 |
layout_footer_scripts | </body> 前的脚本区 | 加载 JS / 统计代码 |
layout_body_end | </body> 之前 | 底部 DIY / 悬浮按钮 |
视图钩子
| 钩子名 | 触发点 | 参数 |
|---|---|---|
view_before | 模板渲染前 | $name, $data |
view_after | 模板渲染后 | $name, $data |
传统模板钩子(兼容)
template_head / body_start / header_before / header_after / before_content / after_content / footer_before / footer_after / template_footer / body_end
前台分发钩子
| 钩子名 | 触发点 | 参数 |
|---|---|---|
dispatch_start | 分发开始 | 无 |
dispatch_end | 分发结束(shutdown 兜底) | 无 |
dispatch_matched | 路由匹配后 | &$matched |
dispatch_plugin_route | __plugin_path 分发 | $pluginName, $rest |
public_action_xxx | ?action=xxx 分发前 | 无 |
8.4 内容与评论钩子
| 钩子名 | 触发点 | 参数 |
|---|---|---|
log_list_query | 列表查询前 | &$cond, $page, $perPage |
log_list_result | 列表查询后 | &$logs, $page, $total |
log_display_before / log_display_after | 详情渲染前后 | $log |
log_content | 正文输出 | $log |
log_save_before / log_save_after | 文章保存 | &$data / $gid |
comment_save_before | 评论提交前 | &$data |
comment_save_after | 评论插入后 | $cid, $data |
comment_section_before / comment_section_after | 评论区前后 | $log |
8.5 相对 URL 陷阱(★ v3.2 最重要)
结论已在第 2.6 节给出。这里补充实战细节。
8.5.1 论坛插件的"删除帖子"故障复盘
// 原来的代码
return '<form method="post" action="?' . forum_h($qs) . '" ...>'
故障链条:
- 帖子页面 URL:
http://li.zgzjw.top/forum/thread/5 - 表单 HTML:
<form method="post" action="?action=forum_delete&id=5"> - 浏览器解析
action="?xxx"→ 当前路径 + query:http://li.zgzjw.top/forum/thread/5?action=forum_delete&id=5 - 请求发出后,服务器(Nginx + LiteLog 重写)优先匹配了
/forum/thread/5的路由 Forum_dispatchPluginRoute('forum', 'thread/5')被调用$_GET['action'] = 'forum_delete',但__plugin_path也已是forum,$rest = 'thread/5'- 路由到
Forum_routeThread(),显示帖子详情(或者根本不执行删除) redirect()后回到同页面 → 用户看到帖子还在
8.5.2 修复后
$formAction = BLOG_URL . '?' . $qs; // 绝对 URL
return '<form method="post" action="' . forum_h($formAction) . '" ...>';
生成的 HTML:
<form method="post" action="http://li.zgzjw.top/?action=forum_delete&id=5">
浏览器完全不会把它解析成当前路径,请求直达核心的 public_action_forum_delete。
8.5.3 兜底:即使绝对 URL 也没生效
在插件的路由处理器里,显式从 $_POST 和 $_GET 分别读,不依赖 $_REQUEST(受 request_order 配置影响):
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
8.6 输出缓冲与响应拦截
8.6.1 六条铁律
| # | 铁律 |
|---|---|
| 1 | 不要用 ob_start('callback') 绑定回调做页面拦截 |
| 2 | 不要在 ob_start 的回调里再 ob_start |
| 3 | 回调必须处理 null——!is_string($html) 时 return '' |
| 4 | 优先用 layout_* 锚点,其次 dispatch_end + 手动捕获 |
| 5 | 绝不要在 ob_start 和 ob_get_clean 之间抛异常 |
| 6 | 用 Output::capture() 而不是裸 ob_start / ob_get_clean |
8.6.2 优先用 layout_* 锚点
99% 的插件不需要碰 ob_start。想往页面上放内容?用 layout_*:
if (class_exists('Hook')) {
try {
Hook::addAction('layout_body_start', function () {
echo '<div class="my-banner">欢迎访问</div>';
});
Hook::addAction('layout_body_end', function () {
echo '<div class="my-floating-help">...</div>';
});
} catch (Throwable $e) { /* 写日志 */ }
}
8.6.3 Output 类 API
$html = Output::capture(function () use ($data) {
echo '<ul>';
foreach ($data as $row) echo '<li>' . h($row) . '</li>';
echo '</ul>';
});
Output::start('my_region');
echo '...';
$html = Output::end('my_region');
$html = Output::wrap('my_block', function () {
echo '...';
});
第 9 章:数据库与模型
9.1 Database 类精确签名
| 方法 | 返回值 |
|---|---|
query($sql, $params = []) | PDOStatement(可 ->rowCount()) |
fetchAll($sql, $params = []) | array(空时 []) |
fetchOne($sql, $params = []) | array \ |
fetchColumn($sql, $params = []) | 标量 |
insert($table, $data) | lastInsertId 字符串(需 (int)) |
update($table, $data, $where, $whereParams = []) | true(不是行数) |
delete($table, $where, $params = []) | true |
三大陷阱:
fetchOne()失败返回false(不是null)insert()返回字符串lastInsertId——必须(int)$newIdupdate()/delete()返回true——要行数用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) {
return ['ok' => false, 'msg' => '余额不足'];
}
9.2 核心表清单
{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(1)) / 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}option
id(PK) / option_name(UNIQUE) / option_value(TEXT,永远是字符串) / autoload
Option::get() 返回永远是 string,判断用 === '1' / === 'y'。
9.3 $DB 使用示例
global $DB;
// 查一条
$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");
// 插入
$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]);
// 判断命中
$stmt = $DB->query("UPDATE ... WHERE uid = ? AND credit > 0", [(int)$uid]);
if ($stmt->rowCount() === 0) { /* 未命中 */ }
// 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);
}
// 事务
$pdo = $DB->pdo();
$pdo->beginTransaction();
try {
// ...
$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() / 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() |
9.5 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);
键名强制前缀 {插件名}_。
★ v3.3 补充:Option::set($key, $value, $autoload = null) 从 v2.1 起新增第三参数:
null(默认):关键 key(如current_template/url_rewrite)→'y',其余 →'n''y':强制 autoload(每次请求都读入内存)'n':强制不 autoload(按需读)
判定原则:每请求都读的配置用 'y',偶尔读的用默认;超过 3 个 key 要在同一请求读取,用 Option::preload() 批量预取(见第 21.4 节)。
第 10 章:视图与模板
10.1 前台视图必须 include 主题 header/footer
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
$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') 解析到当前主题的 header.php,它已经输出了骨架。
10.2 前台评论表单必须加 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>
10.3 视图顶部守卫模板
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!function_exists('MP_isEnabled')) {
echo '<div class="error">插件未正确加载</div>';
return;
}
$page_title = '我的插件 - ' . Option::get('site_title', '');
$title = $page_title;
$__pageTitle = $page_title;
include View::resolve('header');
?>
<!-- 内容 -->
<?php include View::resolve('footer'); ?>
10.4 后台视图
后台视图由 PluginRegistry::addAdminPage 的 callback 调用,内核已包好 admin/views/layout.php,所以后台视图不要 include 主题 header/footer。
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 / .empty / .error / .pagination
10.7 页面标题三变量兜底(★ v3.2 新增)
本轮修复战最顽固的问题:无论怎么改插件视图的 $page_title,浏览器标签始终显示站点名。
10.7.1 问题根源
主题有多套 header:
| 环境 | 加载文件 | 读取变量 |
|---|---|---|
| PC | templates/default/header.php | 可能是 $page_title / $title / 硬编码站点名 |
| 移动端 | templates/default/mobile/header.php | 通常是 $__pageTitle |
再加上:isMobileDevice() 判断优先级是 ?force_view → Cookie litelog_view → UA,PC 浏览器也可能因为 Cookie 命中而走移动端模板。
10.7.2 铁律:视图顶部同时设置三个变量
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
/* ★ 三变量兜底:PC 端、移动端、v3.2 规范 */
$page_title = '每日一文 - ' . Option::get('site_title', '');
$title = $page_title;
$__pageTitle = $page_title;
include View::resolve('header');
?>
$page_title:主流通用变量名$title:v3.2 规范要求(部分主题读这个)$__pageTitle:移动端模板专用
10.7.3 主题端兼容(如果你能改主题)
templates/default/header.php 里:
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
/* ★ 多变量兜底:$page_title → $title → $__pageTitle → 站点名 */
$__final_title = '';
if (isset($page_title) && $page_title !== '') {
$__final_title = $page_title;
} elseif (isset($title) && $title !== '') {
$__final_title = $title;
} elseif (isset($__pageTitle) && $__pageTitle !== '') {
$__final_title = $__pageTitle;
} else {
$__final_title = Option::get('site_title', '');
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title><?= h($__final_title) ?></title>
templates/default/mobile/header.php 里类似处理。
10.7.4 终极方案:JS 强制覆盖 document.title
当主题 header 硬编码站点名、或者变量名不可预测时,从视图顶部挂 layout_head_end 钩子注入 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['__da_title_hooked'])) {
$GLOBALS['__da_title_hooked'] = true;
$__da_title = $page_title;
if (class_exists('Hook')) {
try {
$__da_js = '<script>(function(){var t='
. json_encode($__da_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 ($__da_js) {
if (empty($GLOBALS['__da_active'])) return;
echo $__da_js;
});
Hook::addAction('template_head', function () use ($__da_js) {
if (empty($GLOBALS['__da_active'])) return;
echo $__da_js;
});
} catch (Throwable $e) {}
}
}
include View::resolve('header');
?>
为什么这样绝对有效:
| 主题 header 读什么 | 效果 |
|---|---|
$page_title | ✅ 已经对 |
$title | ✅ 已经对 |
$__pageTitle | ✅ 已经对 |
| 硬编码站点名 | ✅ JS 覆盖 document.title |
| 什么都不输出 | ✅ 有 <title> 才覆盖 |
| 用了缓存/CDN | ✅ 只影响客户端显示,绕过服务端缓存 |
关键设计:
- 只在
__da_active标记时注入,不污染其它页面 - 用
static或全局标志位防重复注入 - 用
json_encode转义标题,防 XSS
第 11 章:资源加载
11.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';
兼容老主题:两个都注册,用 static 去重:
Hook::addAction('layout_head_end', 'MP_injectCss');
Hook::addAction('template_head', 'MP_injectCss'); // 老主题兜底
11.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');
11.3 数据目录保护
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>
第 12 章:小工具与首页模式扩展
12.1 自定义小工具
★ v3.3 新增:从内核 v2.1 起,Widget 基类新增 $cacheable / $cacheTtl 属性。自定义 Widget 必须显式声明 $cacheable,否则默认 true(内容 5 分钟内不会变)。
class Widget_MyBlock extends Widget {
public $id = 'my_block';
public $name = '我的模块';
public $description = '展示自定义内容';
/* ★ v3.3:必须声明缓存策略
true → 内容稳定,允许缓存(默认 TTL 300 秒)
false → 随机 / 时变内容,每次请求都渲染
也可设置 $cacheTtl = 60; 单独控制此 widget 的 TTL */
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 ?: []);
$items = $this->fetchItems($s['count']); // 内部查询
ob_start();
echo '<ul>';
foreach ($items as $it) {
echo '<li>' . $this->h($it) . '</li>';
}
echo '</ul>';
$this->output($instanceId, $s['title'], ob_get_clean());
}
}
Hook::addAction('register_widgets', function () {
WidgetRegistry::register(new Widget_MyBlock());
});
内置基类方法:fieldText / fieldNumber / fieldSelect / output
表单字段必须带 data-setting="键名"(内核 widgets.js 依赖此属性)。
缓存策略判定表(详见第 21.5 节):
| 场景 | $cacheable |
|---|---|
| 最新文章 / 分类 / 标签 / 归档 | true |
| 随机文章 / 随机标签 | false |
日历(依赖 $_GET['cal']) | false |
| 用户头像 / 在线人数 | false 或 cacheTtl = 60 |
| 广告位 / 文本块 / 自定义 HTML | true |
| 统计数字(文章数、评论数) | true(5 分钟延迟可接受) |
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 定义常量 → 加载 lib → 建 PDO
↓
★ isMobileDevice() 判断 → 加载 mobile/router.php(可能 exit)
↓
loadActivePlugins() → 加载插件主文件(注册钩子)
↓
Dispatcher::dispatch()
├─ Hook::doAction('dispatch_start')
├─ 插件通用路由(__plugin_path)
├─ 标准 Rewrite URL 解析
├─ PluginRegistry::matchFrontendRoute()
├─ public_action_{action}
├─ dispatch_matched
├─ 控制器执行 → View::display()(触发 view_before / view_after)
└─ dispatch_end(有 shutdown 兜底)
13.2 会话安全
内核 v1.2 已强化:
| 项 | 值 |
|---|---|
session_regenerate_id(true) | 登录成功后调用 |
| Cookie HttpOnly | true |
| Cookie SameSite | Lax |
| Cookie Secure | HTTPS 自动开启 |
use_strict_mode | 1 |
| Session 名 | LITELOG_SID |
13.3 exit / header / die 边界表
| 场景 | exit | header |
|---|---|---|
| 主文件顶层 | ❌ | ❌ |
frontend/routes.php 回调 | ✅ | ✅ |
{插件名}_callback.php | ❌ | ❌ |
渲染钩子(layout_* / template_head / body_end) | ❌ 红线 3 | ❌ |
public_action_xxx | ✅ 必须 | ✅ 必须 |
dispatch_plugin_route | ✅ | ✅ |
comment_save_before / comment_save_after | ❌ | ❌ |
plugin_activate / plugin_deactivate | ❌ | ❌ |
view_before / view_after | ❌ | ❌ |
13.4 命名约定
| 符号 | 命名 | 示例 |
|---|---|---|
| 函数 | {前缀}_{CamelCase} | ICG_isEnabled() |
| 常量 | {前缀}_UPPER | ICG_VER |
| 类 | {PrefixCamel} / {Prefix_Camel} | Widget_IconGen |
| 表名 | {DB_PREFIX}{插件名}_{表名} | lt_icon_generator_items |
| Option 键 | {插件名}_{键名} | icon_generator_enabled |
核心已占用:Forum_* / UserGroup_* / Uc_* / Widget_* / HomepageMode_* / DIY_* / Output / Csrf / Auth
13.5 路径常量与斜杠
| 常量 | 结尾斜杠 |
|---|---|
LITELOG_ROOT | ✅ 带 |
CONTENT_PATH | ✅ 带 |
ADMIN_PATH | ✅ 带 |
INCLUDE_PATH | ✅ 带 |
BLOG_URL | ✅ 带 |
DB_PREFIX | ❌ 无 |
// ✅
$file = CONTENT_PATH . 'plugins/my_plugin/data.json';
$url = BLOG_URL . 'content/plugins/my_plugin/assets/admin.css';
// ❌ 多一个斜杠
$url = BLOG_URL . '/content/plugins/...';
13.6 移动端 router 会抢跑(★ v3.2 新增)
13.6.1 机制
init.php 里的执行顺序:
define('BLOG_URL', ...);
// ★ 移动端 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 分支——一旦命中并 exit,loadActivePlugins() 根本不会执行,插件注册的所有钩子都不会生效。
13.6.2 影响
- 移动端访问插件页面 → 插件钩子没注册 → 404 或显示博客首页
- 前台资源注入(CSS / JS)不生效
- 插件路由(
dispatch_plugin_route)不触发
13.6.3 应对
方案 A:插件自己不依赖 mobile/router.php。所有路由都用 PluginRegistry::addFrontendRoute() 注册,它是 dispatch() 内部匹配的,不受 mobile router 影响。
方案 B:让 mobile/router.php 支持"未匹配时 fallthrough"(return 而不是 exit)。默认主题的 router 已经这么做了,但某些插件(如论坛)可能在里面有 exit 分支。
方案 C:核心 dispatcher 补丁(第 7.6 节方案 B),在 dispatch_start 后立即识别 /插件名/xxx,此时机在所有 router 之后。
13.6.4 排错
看到 content/backup/mobile-router-error.log 有记录,说明 mobile router 出了问题。查看这个日志。
第 14 章:API 参考
14.4 AJAX 端点九条铁律
- 第一行
header('Content-Type: application/json; charset=utf-8') - 登录校验
- CSRF 校验
- 方法校验(写操作拒绝 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');
// 1) 登录校验
if (empty($_SESSION['uid'])) {
echo json_encode(['ok' => false, 'msg' => '请先登录'], JSON_UNESCAPED_UNICODE);
exit;
}
$uid = (int)$_SESSION['uid'];
// 2) 方法校验
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'msg' => '方法不允许'], JSON_UNESCAPED_UNICODE);
exit;
}
// 3) CSRF 校验
if (class_exists('Csrf')) {
$sent = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? $_POST['_token'] ?? '';
if ($sent === '' || !hash_equals(Csrf::token(), $sent)) {
echo json_encode(['ok' => false, 'msg' => 'CSRF 校验失败,请刷新重试'], JSON_UNESCAPED_UNICODE);
exit;
}
}
// 4) 参数校验
$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 {
// ... 业务 ...
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;
}
}
}
前端调用:
后台页面(layout.php 已全局包装 fetch,同源请求自动加 X-CSRF-Token):
fetch('?action=my_plugin_ajax', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: 1, points: 10 })
})
前台页面(需要手动加 token):
var token = document.querySelector('meta[name="csrf-token"]').content;
fetch('<?= BLOG_URL ?>?action=my_plugin_ajax', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token
},
body: JSON.stringify({ item_id: 1, points: 10 })
})
第 15 章:常见坑清单
15.1 安全
- 路径穿越——文件名过
basename() - SQL 注入——占位符绑定
- XSS——输出过
h() - IDOR——按 ID 操作必校验资源存在
- 上传——扩展名白名单 + 真实图片校验 + 随机文件名 + 像素上限
- 开放重定向——
redirect($url)只允许本站 URL - CSRF——所有 POST 请求带
_token或X-CSRF-Token - 权限——后台操作前
Auth::requireCap() - ★ v3.2:相对 URL——所有表单
action必须是绝对 URL
15.2 部署兼容
- 不硬编码路径——URL 拼
BLOG_URL,路径拼CONTENT_PATH/DIR - 子目录部署——假设站点可能在
https://example.com/blog/ - 大小写敏感——目录名小写下划线
- ★ v3.2:Cookie
litelog_view=mobile——PC 也可能走移动模板
15.3 结构规范
- 一目录一插件
- 不留临时文件
- 不改核心文件(除必要的 dispatcher 补丁)
- option 加插件名前缀
- 生命周期文件不输出内容
- UTF-8 无 BOM,LF 行尾,
php -l校验 - ★ v3.2:PHP 标签合并——避免连续两个
<?php
15.4 输出缓冲陷阱
- 不要用
ob_start('callback') - 回调必须判
is_string($html),非字符串返回'' - 长度守卫:处理后 < 原始 50% 时返回原始
ob_start回调内不能ob_start- 优先用
layout_*锚点 - 优先用
Output::capture()
15.5 兼容性
$DB是全局$data引用参数——log_save_before/comment_save_before必须function (&$data, ...)redirect()已自带exit$_SESSION['uid']是登录标识fetchOne()失败返回falseinsert()返回字符串lastInsertId——必须(int)update()/delete()返回trueOption::get()第二参数永远''- ★ v3.2:
$_REQUEST受request_order影响——显式用$_POST/$_GET
15.6 标题相关(★ v3.2 新增)
- 主题 header 可能读
$page_title/$title/$__pageTitle - 移动端 header 通常读
$__pageTitle - 三变量都要设置
- 主题 header 硬编码时用 JS 覆盖
document.title
第 16 章:调试方法
16.1 遇到"行为不符预期"时的第一反应
不要猜。要读。
| 症状 | 第一步 |
|---|---|
| 后台 500 | 打开 display_errors,看报错行号 |
| 前台白屏 | F12 Console 看 JS;Network 看 HTML |
| 路由不生效 | 读 dispatcher.php |
| 标题不对 | 读主题 header.php 和 mobile/header.php |
| CSS/JS 没加载 | F12 Network 看状态码 |
| CSRF 校验失败 | 检查表单是否有 Csrf::field();AJAX 是否带 X-CSRF-Token |
| 权限拒绝 | 检查 Auth::can() / Auth::requireCap() 的能力名 |
| 删除/修改无效 | 检查表单 action 是不是相对 URL |
| 语法错误白屏 | php -l 检查每个文件 |
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
);
16.3 var_dump 短路调试
var_dump($page_title);
exit;
16.4 CSRF 问题专项排查
| 症状 | 排查 |
|---|---|
表单提交返回 {"ok":false,"msg":"缺少 CSRF 令牌"} | 表单是否加 Csrf::field() |
| AJAX 返回同上 | 请求头是否有 X-CSRF-Token |
| 页面显示 HTML 403 | 请求类型不是 JSON |
| 登录后操作正常,几小时后失败 | Session 过期,刷新页面重试 |
16.5 诊断脚本规范(★ v3.2 新增)
本轮修复战的关键工具。当用户无法提供错误信息、页面白屏无提示时,让用户放一个独立诊断脚本到根目录是最高效的定位方式。
16.5.1 完整诊断脚本模板
保存为 _diag_plugin.php,放网站根目录:
<?php
/**
* 插件诊断脚本
* 用法:放到网站根目录,浏览器访问 /_diag_plugin.php
* 用完请删除!
*/
/* ★ 关键 1:关掉所有缓冲,避免诊断脚本自己白屏 */
while (ob_get_level() > 0) { @ob_end_clean(); }
/* ★ 关键 2:先定义 LITELOG_ROOT,否则 init.php 会 exit('Access Denied') */
if (!defined('LITELOG_ROOT')) {
define('LITELOG_ROOT', __DIR__ . DIRECTORY_SEPARATOR);
}
/* ★ 关键 3:强制桌面视图,避免移动端 router 提前 exit */
$_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:-apple-system,Consolas,Menlo,monospace;font-size:13px;
line-height:1.65;padding:22px;background:#0f172a;color:#e2e8f0;margin:0}
h1{color:#60a5fa;font-size:20px;margin:0 0 6px}
h2{color:#a78bfa;font-size:15px;margin:26px 0 10px;padding-bottom:6px;
border-bottom:1px solid #334155}
.row{display:flex;gap:10px;align-items:baseline;margin:5px 0}
.tag{display:inline-block;padding:1px 9px;border-radius:5px;font-size:11.5px;
font-weight:700;flex-shrink:0}
.tag-ok{background:#064e3b;color:#6ee7b7}
.tag-err{background:#7f1d1d;color:#fca5a5}
.tag-warn{background:#78350f;color:#fcd34d}
.tag-info{background:#1e3a8a;color:#93c5fd}
pre{background:#1e293b;padding:11px 14px;border-radius:7px;overflow-x:auto;
white-space:pre-wrap;word-break:break-all;margin:6px 0;
font-size:12.5px;border:1px solid #334155}
</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 class="row"><span class="tag tag-' . $tag . '">'
. strtoupper($tag) . '</span><span>' . $text . '</span></div>';
}
function diag_h2($t) {
echo '<h2>' . htmlspecialchars($t, ENT_QUOTES, 'UTF-8') . '</h2>';
}
/* ============ 阶段 1:加载核心 ============ */
diag_h2('阶段 1 · 加载 LiteLog 核心');
$initFile = LITELOG_ROOT . 'init.php';
if (!is_file($initFile)) {
diag_row('err', 'init.php 不存在');
echo '</body></html>'; exit;
}
try {
require_once $initFile;
diag_row('ok', 'init.php 加载完成');
} catch (Throwable $e) {
diag_row('err', 'init.php 抛异常:' . htmlspecialchars($e->getMessage()));
echo '<pre>' . htmlspecialchars($e->getFile() . ':' . $e->getLine()) . '</pre>';
echo '</body></html>'; exit;
}
/* ============ 阶段 2:核心类与常量 ============ */
diag_h2('阶段 2 · 核心类与常量');
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) {
if (defined($c)) {
diag_row('ok', '常量 ' . $c . ' = ' . htmlspecialchars((string)constant($c)));
} else {
diag_row('err', '常量 ' . $c . ' 未定义');
}
}
/* ============ 阶段 3:插件状态 ============ */
diag_h2('阶段 3 · 插件状态');
$pluginName = 'daily_article'; // ← 修改为你的插件名
$active = (string)Option::get('active_plugins', '');
if (strpos(',' . $active . ',', ',' . $pluginName . ',') !== false) {
diag_row('ok', $pluginName . ' 已启用');
} else {
diag_row('err', $pluginName . ' 未启用');
}
diag_row('info', 'url_rewrite = ' . Option::get('url_rewrite', 'n'));
diag_row('info', 'isMobileDevice() = ' . (function_exists('isMobileDevice') && isMobileDevice() ? 'YES' : 'NO'));
/* ============ 阶段 4:视图语法检查 ============ */
diag_h2('阶段 4 · 视图语法检查');
$base = CONTENT_PATH . 'plugins/' . $pluginName . '/';
foreach (['views/detail.php', 'views/index.php', 'controllers/DailyController.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);
if ($ret === 0) {
diag_row('ok', ' └ 语法通过');
} elseif ($ret !== 127) {
diag_row('err', ' └ ' . implode(' ', $out));
}
}
}
/* ============ 阶段 5:路由识别 ============ */
diag_h2('阶段 5 · 路由识别');
$dispFile = LITELOG_ROOT . 'include/lib/dispatcher.php';
$dispContent = @file_get_contents($dispFile);
if (strpos($dispContent, 'matchPluginUri') !== false) {
diag_row('ok', 'dispatcher.php 已含伪静态路由补丁');
} else {
diag_row('warn', 'dispatcher.php 未含伪静态补丁');
}
/* ============ 阶段 6:错误日志 ============ */
diag_h2('阶段 6 · 最近错误日志');
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>';
echo '<p>请把整页内容截图发给我。</p>';
echo '<p>⚠️ 用完请删除 _diag_plugin.php</p>';
?>
</body>
</html>
16.5.2 关键要点
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 - 每步
try/catch——不让诊断脚本自己崩 php -l检查语法——用exec或 fallback
16.5.3 输出解读
- 所有阶段 OK → 插件本身没问题,问题在别处
- 阶段 4 出现
syntax error→ 视图文件有 PHP 语法错误(本轮"每日一文"的问题) - 阶段 5 显示"未含伪静态补丁" → 需要应用 dispatcher 补丁
- 阶段 6 有历史记录 → 可以从日志中找到具体报错
16.6 三点式日志探针法(★ v3.2 新增)
当诊断脚本也无法定位时(例如错误发生在渲染中且被吞掉),用三点式日志探针逐步确认执行到哪一步:
16.6.1 原理
在关键位置插入只写日志、不输出的探针:
@file_put_contents(
CONTENT_PATH . 'backup/probe.log',
'[' . date('Y-m-d H:i:s') . '] 标记 ①' . "\n",
FILE_APPEND | LOCK_EX
);
不输出到页面是关键——避免探针本身触发新的错误。
16.6.2 三点布设
探针 ①:{插件名}.php 主文件,define('XXX_REGISTERED', true) 之后
if (defined('XXX_HOOKS_REGISTERED')) return;
define('XXX_HOOKS_REGISTERED', true);
@file_put_contents(
CONTENT_PATH . 'backup/probe.log',
'[' . date('Y-m-d H:i:s') . '] ① 主文件已加载 URI=' . ($_SERVER['REQUEST_URI'] ?? '') . "\n",
FILE_APPEND | LOCK_EX
);
探针 ②:控制器方法开头(如 displayDetail)
public function displayDetail($id = 0) {
@file_put_contents(
CONTENT_PATH . 'backup/probe.log',
'[' . date('Y-m-d H:i:s') . '] ② displayDetail 进入 id=' . (int)$id . "\n",
FILE_APPEND | LOCK_EX
);
$GLOBALS['__da_active'] = true;
// ...
}
探针 ③:视图文件顶部
<?php
@file_put_contents(
CONTENT_PATH . 'backup/probe.log',
'[' . date('Y-m-d H:i:s') . '] ③ detail.php 开始执行' . "\n",
FILE_APPEND | LOCK_EX
);
?>
16.6.3 结果解读
probe.log 内容 | 含义 | 下一步 |
|---|---|---|
| 文件不存在 | 请求根本没到插件 | dispatcher 路由没匹配 |
| 只有 ① | 主文件加载了,但控制器没被调用 | dispatch_plugin_route 没触发 |
| 有 ① + ② | 控制器进入了,但视图没执行 | 控制器内部崩了 |
| 有 ① + ② + ③ | 视图开始执行了,崩在视图内部 | 视图有语法或运行时错误 |
| 只有 ①,内容重复 20 次 | 插件被重复加载 | 有文件循环 require |
升级:把探针增加到 4 个(在每个可能的"拐点"),逐步缩小范围。
16.6.4 清理
排错完成后务必删除所有探针代码和 probe.log。
第 17 章:完整最小骨架
17.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
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!');
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) {
@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 $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() { /* 保留数据 */ }
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!');
/* 前置守卫 */
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'); ?>
17.6 my_plugin_setting.php
<?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();
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>
17.7 assets/style.css / assets/script.js
空文件也必须存在(否则 filemtime() 会 warning)。
第 18 章:交付前自检清单
18.1 结构
- [ ] 目录名 = 主文件名(小写下划线)
- [ ] 有
{插件名}.php+{插件名}_callback.php - [ ] 有
frontend/routes.php(如需要) - [ ] 有
views/目录 - [ ] 有
assets/目录(哪怕空文件) - [ ] 有
{插件名}_setting.php(如需设置页)
18.2 安全
- [ ] 所有 SQL 用占位符
- [ ] 所有输出过
h() - [ ] 无文件操作,或过
basename() - [ ] AJAX 写操作有登录校验
- [ ] 原子扣减用
query()->rowCount() - [ ] 无
unserialize/eval - [ ]
IN (...)用array_map('intval')+ 占位符 - [ ] 未覆盖 PDO 属性
- [ ] 所有 POST 表单有
Csrf::field() - [ ] 所有 AJAX 带
X-CSRF-Token - [ ] 后台入口调
Auth::requireCap() - [ ] ★ v3.2:所有表单
action是绝对 URL
18.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()守卫 - [ ]
frontend/routes.php在主文件里被require_once - [ ] ★ v3.2:每个
.php文件都通过php -l
18.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 - [ ] ★ v3.2:PHP 标签合并,避免连续两个
<?php
18.5 ⭐ 输出缓冲
- [ ] 没有使用
ob_start('callback')做页面拦截 - [ ] 如果必须用输出缓冲,用
Output::capture() - [ ] 如果注册了
dispatch_end,逻辑里有长度守卫 - [ ] 如果注册了
dispatch_end,逻辑里有非字符串输入处理
18.6 API 使用
- [ ]
$DB->fetchOne()判false - [ ]
$DB->insert()返回值(int)转换 - [ ] 需要行数用
query()->rowCount() - [ ] 用户名用
nickname ?: username - [ ] URL 拼接不重复加斜杠
- [ ] 函数/常量/表名有插件前缀
- [ ] AJAX 端点九条铁律全过
- [ ] ★ v3.2:
$_REQUEST换成显式$_POST/$_GET
18.7 ⭐ 性能(★ v3.3 新增)
- [ ] 主文件顶层没有查库 / IO / HTTP / 重循环(详见 21.2)
- [ ] 每个 Hook 回调执行 < 5ms,无 DB 查询、无远程请求(详见 21.3)
- [ ] 单次请求读 ≥ 3 个 option,用了
Option::preload()批量预取(详见 21.4) - [ ] 每请求都读的 option 设了
autoload='y',偶尔读的用默认'n'(详见 21.4) - [ ] 自定义 Widget 声明了
$cacheable,随机 / 时变内容设为false(详见 21.5) - [ ] Widget
render()内无 N+1 查询,循环查询改为 GROUP BY(详见 21.5) - [ ] 无
ORDER BY RAND(),改用采样法(详见 21.5) - [ ] 无
SELECT *拉全表,明确列名 + LIMIT(详见 21.6) - [ ] 大表查询有分页 / 限行(详见 21.6)
- [ ] 循环内无 DB 查询,改用 IN 批查(详见 21.6)
- [ ] 自定义表建了合理索引(详见 21.6.4)
- [ ] AJAX 写操作有限流,读操作有缓存(详见 21.7)
- [ ] 超过 3 秒的任务(导入 / 远程 API / 批处理)已队列化(详见 21.8)
- [ ] 关键位置加了
mp_perf_tick()探针,单次请求总和 < 30ms(详见 21.10) - [ ]
SHOW PROCESSLIST观察单次请求 SQL 数 < 10 条(详见 21.10) - [ ]
memory_get_peak_usage()观察内存增量 < 5MB(详见 21.10)
18.8 验证
- [ ] 启用 → 访问 → 停用 → 再启用 → 卸载 全流程无异常
- [ ] 浏览器标签页标题正确
- [ ] F12 Console 无报错,Network 全 200
- [ ] 卸载后表删除、option 清空
- [ ] ★ v3.2:用
_diag_plugin.php走查一遍
第 19 章:开发经验教训(38 条铁律)
v3.3 从 30 条扩到 38 条,加入性能规范的 8 条铁律。
19.1 三十八条铁律
| # | 教训 |
|---|---|
| 1 | 规范是地图,内核是地形。地图和地形冲突时,信地形 |
| 2 | 不确定的机制,先用 error_log / var_dump / 读源码验证,禁止靠猜 |
| 3 | 有可工作参考实例时,先"逐行结构对齐",再谈业务 |
| 4 | 导出/文档没看到某物 ≠ 某物不存在;先问,再断言 |
| 5 | 每条规范都有场景前提,问清上下文再套用 |
| 6 | 少说"一定";多说"请验证" / "如未生效,查这里" |
| 7 | "多键兜底""兼容写法"往往是懒惰的遮羞布 |
| 8 | 视图不能自建 HTML 骨架 |
| 9 | CSS/JS 走 Hook 注入 |
| 10 | 内核只自动加载主文件;子文件要主文件主动 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 | ★ v3.2:所有表单 action 必须是绝对 URL——action="?xxx" 在伪静态路径下会被解析为当前路径 + query,路由错乱 |
| 24 | ★ v3.2:JS 动态生成表单的 action 也必须是绝对 URL |
| 25 | ★ v3.2:视图顶部同时设置 $page_title / $title / $__pageTitle——主题 header 变量名不可预测 |
| 26 | ★ v3.2:主题 header 硬编码时,用 JS 强制覆盖 document.title——这是终极可靠方案 |
| 27 | ★ v3.2:连续两个 <?php 是陷阱——合并为一个大 PHP 块,只在输出 HTML 时闭合 |
| 28 | ★ v3.2:诊断脚本必须先 define('LITELOG_ROOT', ...)——否则 init.php 会 exit('Access Denied') |
| 29 | ★ v3.2:移动端 router.php 会抢跑并 exit——init.php 里它在 loadActivePlugins() 之前执行 |
| 30 | ★ v3.2:$_REQUEST 受 request_order 影响——显式用 $_POST / $_GET |
| 31 | ★ v3.3:主文件顶层禁止任何副作用——查库、IO、HTTP、重循环必须移到 register/routes 或 controller |
| 32 | ★ v3.3:Hook 回调必须 O(1) 或 O(极少),单次 < 5ms——禁止在回调里做 IO、查库、远程请求 |
| 33 | ★ v3.3:自定义 Widget 必须声明 $cacheable——随机/时变内容必须设为 false,其余保持默认 true |
| 34 | ★ v3.3:循环里禁止查询——N+1 必须改为 IN 批查或 GROUP BY |
| 35 | **★ v3.3:禁止 SELECT 拉全表*——明确列名 + LIMIT 或分页 |
| 36 | ★ v3.3:禁止 ORDER BY RAND()——万行以上会拖垮数据库,改用采样法 |
| 37 | ★ v3.3:单次请求读 ≥ 3 个 option 必须用 Option::preload() 批量预取 |
| 38 | ★ v3.3:超过 3 秒的任务必须队列化——前台只返回任务 ID,由 cron/CLI 消费 |
19.2 常见错误推理链
❌ 错误链 A:路由不生效 → "可能 PluginRegistry 不存在" → 改用 Hook → 引入新问题
✅ 正确链:读 dispatcher.php → 发现路由在核心匹配 → 检查 PluginRegistry::matchFrontendRoute → 检查自己的注册代码 → 修复
❌ 错误链 B:CSRF 报错 → "可能 Csrf 类没加载" → 加 class_exists 判断 → 还是错
✅ 正确链:CSRF 报错 → 看报错是"缺少令牌"还是"不匹配" → "缺少"就检查表单/AJAX 是否带 token → "不匹配"就检查 session 是否丢了
❌ 错误链 C:删除按钮点了没反应 → "可能权限问题" → 加日志 → 还是没反应
✅ 正确链:查看 F12 Network → 表单提交到了哪个 URL → 如果是 /forum/thread/5?action=... 而不是 /?action=... → 相对 URL 陷阱
❌ 错误链 D:标题不对 → 改 $page_title → 无效 → 改 $title → 无效 → 改 $__pageTitle → 无效 → "放弃"
✅ 正确链:读主题 header.php → 看它读哪个变量 → 如果硬编码站点名 → 用 JS 覆盖 document.title(万能解)
❌ 错误链 E:白屏 → "可能数据库问题" → 检查 DB → 正常 → "可能缓存问题" → 清缓存 → 还白屏
✅ 正确链:白屏 → 用 _diag_plugin.php → 阶段 4 显示 syntax error on line 17 → 视图文件有语法错误 → php -l 验证 → 修复
❌ 错误链 F(v3.3 新增):网站慢 → "可能是数据库" → 加索引 → 还是慢 → "可能是 PHP 版本" → 升级 PHP → 还是慢
✅ 正确链:网站慢 → 用 mp_perf_tick() 打点 → 定位哪个 Hook / Widget 慢 → 检查该 Widget 是否 N+1 / ORDER BY RAND() → 加缓存 + 改查询 → 复测 P95
第 20 章:给 AI 的指令模板
20.1 从零开发新插件
请按 LiteLog 插件开发规范 v3.3 开发一个 XXX 插件:
【附件:LiteLog 插件开发规范 v3.3 完整版.md】
需求:
- 功能:……
- 前台入口:URL ……,action=……
- 后台入口(可选):菜单名「……」,action=……,设置页放 `{插件名}_setting.php`
- 数据表:……
严格遵守本文件所有条款,特别是:
- 第 1 章「500 错误免疫」的 9 条口诀
- 第 2 章「五条硬性安全规则」,尤其 2.6「绝对 URL」
- 第 4 章「自动加载真相」(主文件必须 require 子文件)
- 第 5.3 节「PHP 语法安全写法」
- 第 7.6 节「伪静态路由识别」
- 第 8.5 节「相对 URL 陷阱」
- 第 10 章「视图必须 include 主题 header/footer」+「表单必须加 CSRF」+「三变量兜底」
- 第 11 章「CSS/JS 走 layout_* 注入」
- 第 14.4 节「AJAX 九条铁律」
- ★ 第 18.7 节「性能自检清单」(v3.3 新增)
- ★ 第 21 章「性能规范」(v3.3 新增)
- 第 18 章交付前自检清单
不要修改任何核心文件(除非明确需要 dispatcher 补丁)。完成后给出完整文件列表和每个文件的完整代码。
20.2 排查 500 错误
我的插件启用后后台 500。请根据规范第 1 章排查。
【附件:LiteLog 插件开发规范 v3.3 完整版.md】
报错信息:……
20.3 排查 CSRF 错误
我的插件出现 CSRF 校验失败:……
【附件:LiteLog 插件开发规范 v3.3 完整版.md】
当前代码:(贴表单 / AJAX / 端点)
日志:(content/backup/plugin-error.log)
请按第 2.4 节排查。
20.4 排查"按钮点了没反应"
我的插件里有一个删除按钮,点击后页面刷新了但目标没被删除。
【附件:LiteLog 插件开发规范 v3.3 完整版.md】
当前表单代码:……
浏览器 Network 面板显示的请求 URL:……
请按第 8.5 节「相对 URL 陷阱」排查。
20.5 排查"标题不对"
我的插件页面浏览器标签始终显示站点名,不是插件标题。
【附件:LiteLog 插件开发规范 v3.3 完整版.md】
主题 header.php 的 <title> 那行代码:……
插件视图顶部代码:……
请按第 10.7 节排查。
20.6 排查"网站变慢"(★ v3.3 新增)
启用我的插件后网站变慢:……
【附件:LiteLog 插件开发规范 v3.3 完整版.md】
主文件代码:……
Widget / Hook 代码:……
慢查询 / 压测结果:……
请按第 21 章「性能规范」排查:
1. 主文件顶层有无副作用(21.2)
2. Hook 回调有无 DB/IO/HTTP(21.3)
3. Option 读取有无 preload(21.4)
4. Widget 有无 N+1 / ORDER BY RAND()(21.5)
5. 是否缺索引(21.6.4)
6. 长任务有无队列化(21.8)
7. 给出加缓存的方案(21.9)
20.7 排错通用模板
我的插件出现以下症状:……
【附件:LiteLog 插件开发规范 v3.3 完整版.md】
当前代码:(贴主文件 + 视图 + 路由 + 设置页)
内核相关信息:(贴 dispatcher.php / 主题 header.php)
日志:(content/backup/plugin-error.log)
请按以下顺序排查:
1. 先读内核源码确认机制(不要猜)
2. 再检查代码是否符合机制
3. 给出最小修改方案
4. 给出验证方法
如果需要诊断脚本,请直接给我一个完整的 `_diag_plugin.php`。
第 21 章:性能规范(★ v3.3 新增)
本章针对"插件多时拖慢网站"的普遍问题。原则:单个请求的所有插件开销之和,前台 < 30ms、后台 < 100ms、SQL 增量 < 10 条、内存增量 < 5MB。
21.1 请求生命周期成本预算
| 阶段 | 允许成本 | 说明 |
|---|---|---|
| 主文件加载 | 每插件 < 3ms | 有 OPcache 时通常 < 0.1ms |
| Hook 回调(每个) | < 5ms | 禁止在回调里做 IO、查库、HTTP |
| 视图渲染(每页) | < 50ms | 含所有 widget->render() |
| AJAX 响应 | < 200ms | 含 DB 查询 |
| 后台页面 | < 100ms | 不含插件自己的重活 |
21.2 主文件加载成本
主文件被所有上下文无条件加载。以下行为禁止出现在主文件顶层:
// ❌ 禁止:顶层查库
$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++) { ... }
// ✅ 允许:常量守卫、函数定义、Hook::addAction 注册
if (!defined('MP_VER')) define('MP_VER', '1.0.0');
if (!function_exists('MP_conf')) { ... }
Hook::addAction('layout_head_end', 'MP_injectCss');
如果必须初始化数据,放到 admin/register.php 或 frontend/routes.php,它们只在对应上下文加载。
21.3 Hook 回调耗时约束
每个 Hook 回调必须 O(1) 或 O(极小),单次执行 < 5ms。禁止:
// ❌ 禁止:在 layout_head_end 里查全表
Hook::addAction('layout_head_end', function () {
global $DB;
$rows = $DB->fetchAll("SELECT * FROM lt_blog"); // 每次请求都跑!
foreach ($rows as $r) { ... }
});
// ❌ 禁止:在 view_before 里发起远程请求
Hook::addAction('view_before', function ($name, $data) {
$geo = file_get_contents('https://ipapi.co/json/'); // 每次请求都跑!
});
// ✅ 允许:输出静态 CSS/JS 标签
Hook::addAction('layout_head_end', function () {
echo '<link rel="stylesheet" href="...">';
});
// ✅ 允许:读已 preload 的 option
Hook::addAction('layout_head_end', function () {
if (Option::get('mp_banner_enabled', '') !== '1') return;
echo '<div class="banner">' . h(Option::get('mp_banner_text', '')) . '</div>';
});
重活必须放 AJAX 端点或缓存。回调内如必须查库,加 try/catch 并考虑 Cache::read()。
21.4 Option 读取策略
单次请求读 3 个以上 option,必须使用 Option::preload() 批量预取:
// ❌ 反例:5 次单条 SELECT
$a = Option::get('mp_a', '');
$b = Option::get('mp_b', '');
$c = Option::get('mp_c', '');
$d = Option::get('mp_d', '');
$e = Option::get('mp_e', '');
// ✅ 正例:1 次 SELECT
Option::preload(['mp_a', 'mp_b', 'mp_c', 'mp_d', 'mp_e']);
$a = Option::get('mp_a', '');
$b = Option::get('mp_b', '');
// ...
预取时机:主文件定义完函数后、frontend/routes.php / admin/register.php 加载后。
频繁读的 option 应设 autoload='y':
Option::set('mp_hot_setting', 'value', 'y'); // 每次请求都读 → autoload
Option::set('mp_rare_setting', 'value', 'n'); // 很少读 → 按需查
21.5 Widget 性能规范
自定义 Widget 必须声明 $cacheable:
class Widget_MyBlock extends Widget {
public $cacheable = true; // 内容稳定 → 缓存
// 或
public $cacheable = false; // 随机 / 时变 → 不缓存
}
判定标准:
| 场景 | $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']); // 每个分类一次 COUNT
}
// ✅ 正例:一次 GROUP BY
$rows = $DB->fetchAll(
"SELECT sortid, COUNT(*) AS cnt FROM lt_blog
WHERE hide='n' AND type='blog' GROUP BY sortid");
$counts = array_column($rows, 'cnt', 'sortid');
禁止 ORDER BY RAND():大表上等价于全表排序。
// ❌ 禁止(1 万行以上极慢)
"SELECT * FROM lt_blog ORDER BY RAND() LIMIT 5"
// ✅ 正例:随机 ID 采样
$maxId = (int)$DB->fetchColumn("SELECT MAX(gid) FROM lt_blog WHERE hide='n'");
$ids = [];
while (count($ids) < 5) {
$ids[] = mt_rand(1, $maxId);
}
$in = implode(',', array_map('intval', $ids));
"SELECT * FROM lt_blog WHERE gid IN ($in) AND hide='n' LIMIT 5"
21.6 数据库性能规范
21.6.1 禁止 SELECT * 拉全表
// ❌ 禁止
"SELECT * FROM lt_blog"
// ✅ 正例:明确列 + LIMIT
"SELECT gid, title, date FROM lt_blog WHERE hide='n' LIMIT 10"
21.6.2 大表查询必须分页或限行
// ❌ 禁止:一次拉 10 万行
$rows = $DB->fetchAll("SELECT * FROM lt_comment");
// ✅ 正例:分页
$rows = $DB->fetchAll("SELECT ... FROM lt_comment LIMIT 50 OFFSET ?", [$offset]);
21.6.3 循环内禁止查询
// ❌ 禁止:N+1
foreach ($gids as $gid) {
$row = $DB->fetchOne("SELECT title FROM lt_blog WHERE gid=?", [$gid]);
}
// ✅ 正例:一次 IN 查
$in = implode(',', array_fill(0, count($gids), '?'));
$rows = $DB->fetchAll("SELECT gid, title FROM lt_blog WHERE gid IN ($in)", $gids);
$map = array_column($rows, 'title', 'gid');
21.6.4 复杂统计必须建索引
常用索引(核心已经建好,插件自己建表时参考):
blog(hide, type, date)— 列表页blog(hide, type, views)— 热门blog(sortid, hide)— 分类计数comment(gid, hide, date)— 评论列表tag_blog(tid)/tag_blog(gid)— 标签关联
21.7 AJAX 性能规范
- 写操作必须限流:同一用户 N 秒内同一操作 < 1 次。
B94
- 读操作必须可缓存:即使是 AJAX,能缓存就不查库。
- 批量接口合并请求:前端要 10 个数据,用一次 AJAX 返回全部,而不是 10 次。
- 禁止同步阻塞:远程请求(短信、支付、AI)必须异步化(写队列),不能在 AJAX 里同步等待。
21.8 长任务规范
超过 3 秒的任务不能在请求里同步执行:
- 批量导入文章 / 用户
- 生成大文件 / 压缩包
- 远程 API 调用
- 图片批处理
正确做法:
- 写入队列表
- 由 cron 或 CLI 脚本消费
- 前台只返回任务 ID
// ❌ 禁止
foreach ($files as $f) {
processLargeFile($f); // 100 个文件 × 1 秒 = 100 秒超时
}
// ✅ 正例
foreach ($files as $f) {
$DB->insert('mp_jobs', ['file' => $f, 'status' => 'pending']);
}
echo json_encode(['ok' => true, 'msg' => '已加入队列']);
21.9 缓存策略
| 缓存层级 | 适用场景 | 实现 |
|---|---|---|
请求级 static | 同一请求内多次读同一 option | Option::preload() / static $cache = null |
跨请求 Cache | 5 分钟内的稳定内容 | $GLOBALS['CACHE']->read/write |
| 页面缓存 | 静态页 | 核心没有,可用 CDN |
| OPcache | 所有 PHP 文件 | 服务器配置 |
跨请求缓存:
global $CACHE;
$key = 'mp_data_' . md5(json_encode($params));
$data = $CACHE ? $CACHE->read($key) : false;
if (!is_array($data) || ($data['expire'] ?? 0) < time()) {
$data = [
'value' => expensiveQuery(),
'expire' => time() + 300,
];
if ($CACHE) $CACHE->write($key, $data);
}
return $data['value'];
flush 时机:写数据后清缓存(用 hook 监听 log_save_after 等核心事件,或自己写 mp_flush())。
21.10 性能自检
开发期:在插件主文件加一个可选的性能探针(生产环境删除):
if (!function_exists('mp_perf_tick')) {
function mp_perf_tick($label) {
static $t0 = null;
if ($t0 === null) $t0 = microtime(true);
$dt = (microtime(true) - $t0) * 1000;
@file_put_contents(
CONTENT_PATH . 'backup/perf.log',
sprintf("%.2fms %s\n", $dt, $label),
FILE_APPEND | LOCK_EX
);
}
}
在关键位置调用 mp_perf_tick('主文件加载完')、mp_perf_tick('路由匹配完')、mp_perf_tick('视图渲染完'),观察日志。
上线前:
- 打开
content/backup/perf.log,确认单次请求所有mp_perf_tick之和 < 30ms。 - 用
SHOW PROCESSLIST观察一次请求的 SQL 数,< 10 条。 - 用
memory_get_peak_usage()观察内存增量,< 5MB。 - 用
ab -n 100 -c 10 http://your-site/xxx做压测,P95 < 200ms。
21.11 快速检查卡(打印贴在显示器上)
□ 主文件顶层有查库/IO/HTTP 吗? → 挪到 register/routes/controller
□ Hook 回调里有 DB 查询吗? → 加缓存或挪到 AJAX
□ render() 里有 foreach + 查询吗? → GROUP BY 一次查
□ SELECT * 了吗? → 明确列名
□ ORDER BY RAND() 了吗? → 采样法
□ Option::get 超过 3 次了吗? → preload()
□ 随机 Widget 声明 cacheable=false 了吗?
□ 循环里调 Hook / DB / 远程了吗? → 全部挪出去
□ 一次请求 SQL > 10 条了吗? → 查 PROCESSLIST
□ 一次请求内存增量 > 5MB 了吗? → 查 memory_get_peak_usage
掌握这 21 章 + 38 条铁律,插件对网站性能的影响可以控制在前台 < 30ms、后台 < 100ms 的预算内。
收尾:一句话总结
「define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require,PHP 标签别写乱。」
v3.0 五条核心原则:
① 视图 include 主题 header/footer
② 资源走 layout_* Hook 注入
③ 不要用 ob_start('callback') 拦截页面
④ 必须用输出缓冲时,用 Output::capture() + 长度守卫
⑤ 机制不明,先读源码
v3.1 三条新增:
⑥ 所有 POST 表单加 Csrf::field(),AJAX 带 X-CSRF-Token
⑦ 后台操作前调 Auth::requireCap()
⑧ 插件设置页放 {插件名}_setting.php
v3.2 八条新增(每日一文修复战精华):
⑨ 所有表单 action 必须是绝对 URL——相对 URL 在伪静态路径下会路由错乱
⑩ 标题三变量兜底 + 必要时 JS 覆盖 document.title——主题 header 变量名不可预测
⑪ PHP 标签合并,避免连续两个 <?php——防 Parse error
⑫ 诊断脚本必须先 define('LITELOG_ROOT')——否则被 init.php 拒绝
⑬ 三点式日志探针法——不输出、只写文件,定位白屏崩在哪一步
⑭ 伪静态路由识别——核心 dispatcher 补丁,让所有插件自动支持 /插件名/xxx
⑮ 移动端 router 会抢跑——init.php 里它在 loadActivePlugins() 之前
⑯ $_REQUEST 换成显式 $_POST / $_GET——不受 request_order 影响
v3.3 八条新增(性能规范):
⑰ 主文件顶层禁止副作用——查库、IO、HTTP、重循环必须移到 register/routes 或 controller
⑱ Hook 回调必须 O(1),单次 < 5ms——禁止在回调里做 IO、查库、远程请求
⑲ 自定义 Widget 必须声明 $cacheable——随机/时变内容必须设为 false
⑳ 循环里禁止查询——N+1 必须改为 IN 批查或 GROUP BY
㉑ **禁止 SELECT 拉全表*——明确列名 + LIMIT 或分页
㉒ 禁止 ORDER BY RAND()——万行以上会拖垮数据库,改用采样法
㉓ 单次请求读 ≥ 3 个 option 必须用 Option::preload()——把 N 次查询压成 1 次
㉔ 超过 3 秒的任务必须队列化——前台只返回任务 ID,由 cron/CLI 消费
掌握这 21 章 + 38 条铁律 + 五条核心原则 + 三条安全规则 + 九条红线,99% 的插件能一次跑通,并且不会拖慢网站。
本文件基于 LiteLog 内核 v2.1 + default 主题 v1.3.1 + 每日一文 v1.2.7 完整修复战整理。