版本:4.0(2026-10-13) 定位:既是规范(必须遵守的硬性规则),也是经验(实战中总结的教训),更是手册(可索引、可查阅、可执行的完整参考)。 来源:LiteLog 内核 v1.2 + default 主题 v1.3.1 + 每日一文/论坛插件两轮完整修复战的全部经验。 给 AI 下指令时,请把本文件完整附上。
阅读指引
本手册分 8 个部分,按"从零到一"和"从一到生产"两条路径组织:
| 你的角色 | 推荐阅读顺序 |
|---|---|
| 第一次写插件 | 第 1 部分 → 第 2 部分 → 第 3 部分 → 第 7 部分 |
| 排错 | 第 4 部分 → 第 6 部分 → 第 5 部分(按症状查阅) |
| 代码审查 / 学习 | 第 4 部分 → 第 5 部分 → 第 8 部分附录 D |
| 给 AI 下指令 | 第 8 部分附录 E |
⚠️ 关键条款标记:
- 🔴 红线:违反一票否决
- ⭐ 重点:最容易踩坑的地方
- 📌 口诀:记忆口诀
第 1 部分:入门
第 1 章 · 快速上手(10 分钟跑通第一个插件)
1.1 目标
创建一个名为 hello 的插件,它能在前台 /hello 页面显示"你好,LiteLog"。
1.2 目录结构
content/plugins/hello/
├── hello.php ← 主文件
└── views/
└── main.php ← 前台视图
1.3 主文件 hello.php
<?php
/**
* Plugin Name: Hello
* Description: 最小可运行的示例插件
* Version: 1.0.0
* Author: 你的名字
*/
if (!defined('LITELOG_ROOT')) exit('error!');
/* 定义常量(★ 必须加 defined 守卫) */
if (!defined('HELLO_VER')) define('HELLO_VER', '1.0.0');
/* 注册前台路由 */
if (class_exists('Hook')) {
try {
Hook::addAction('dispatch_plugin_route', function ($pluginName, $rest) {
if ($pluginName !== 'hello') return;
$GLOBALS['__plugin_routed'] = true;
$GLOBALS['__hello_active'] = true;
include __DIR__ . '/views/main.php';
exit;
});
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] hello register: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
}
1.4 视图 views/main.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
/* 三变量兜底(后面第 20 章详述) */
$page_title = 'Hello - ' . Option::get('site_title', '');
$title = $page_title;
$__pageTitle = $page_title;
include View::resolve('header');
?>
<div style="padding:60px 20px;text-align:center">
<h1 style="font-size:32px;color:#2563eb">👋 你好,LiteLog</h1>
<p style="color:#64748b;margin-top:16px">第一个插件已经跑通了。</p>
</div>
<?php include View::resolve('footer'); ?>
1.5 启用与访问
- 把
hello/目录上传到content/plugins/ - 后台 → 插件管理 → 启用
Hello - 访问
http://你的域名/hello
⚠️ 前提条件:核心 dispatcher.php 需支持 /hello 伪静态路由。若不支持,请参阅第 9 章《路由系统》。
第 2 章 · 内核架构总览
2.1 分层结构
┌─────────────────────────────────────────────┐
│ 前台请求 (/post/123.html) │
│ 后台请求 (/admin/?action=logs) │
└──────────────────┬──────────────────────────┘
│
▼
┌────────────────────────┐
│ index.php / admin/ │ 入口
└───────────┬────────────┘
│
▼
┌────────────────────────┐
│ init.php │ 初始化
│ · 常量定义 │
│ · 加载 lib │
│ · 建立 PDO │
│ · 移动端 router │ ← ⭐ 会抢跑
│ · loadActivePlugins() │ ← 你的插件在这里被加载
└───────────┬────────────┘
│
▼
┌────────────────────────┐
│ Dispatcher::dispatch()│ 路由分发
│ · dispatch_start │
│ · __plugin_path │
│ · 核心 Rewrite URL │
│ · PluginRegistry 匹配 │
│ · public_action_xxx │
│ · 控制器 → 视图 │
│ · dispatch_end │
└────────────────────────┘
2.2 核心对象
| 对象 | 职责 | 关键方法 |
|---|---|---|
Database | PDO 封装 | query / fetchOne / insert / update / delete |
Option | 键值存储 | get / set |
Hook | 事件/过滤器 | addAction / doAction / applyFilters |
View | 视图解析 | resolve / display |
Url | URL 生成 | log / sort / tag / author / record |
Csrf | CSRF 防护 | token / field / verify |
Auth | 权限系统 | check / can / requireCap |
Dispatcher | 路由分发 | dispatch |
PluginRegistry | 插件注册表 | addAdminPage / addFrontendRoute |
Output | 输出缓冲 | capture / wrap |
Widget / WidgetRegistry / WidgetArea | 小工具系统 | — |
HomepageMode / HomepageModeRegistry | 首页模式系统 | — |
第 3 章 · 请求生命周期
3.1 前台完整流程
1. index.php 定义 LITELOG_ROOT,require init.php
2. init.php:
├─ 定义 CONTENT_PATH / INCLUDE_PATH / ADMIN_PATH
├─ require config.php → 定义 DB_PREFIX
├─ 启动 Session(HttpOnly + SameSite=Lax)
├─ require 所有 lib/*.php
├─ 建立 PDO($DB 全局)
├─ Option::init() 加载配置
├─ require 所有 model / controller
├─ 定义 BLOG_URL
├─ ★ 移动端 router(isMobileDevice() == true 时加载)
│ └─ ⚠️ 可能 exit,之后所有代码不执行
└─ loadActivePlugins() ← 你的插件在这里加载,注册 Hook
3. Dispatcher::dispatch()
├─ Hook::doAction('dispatch_start')
├─ ★ 若核心已打补丁:识别 /插件名/xxx → __plugin_path
├─ 若 __plugin_path 存在:
│ └─ Hook::doAction('dispatch_plugin_route', $name, $rest)
│ └─ 你的插件路由处理器 → exit
├─ 匹配核心 Rewrite URL(post/sort/tag/...)
├─ PluginRegistry::matchFrontendRoute()
├─ public_action_{action}
├─ dispatch_matched → 控制器执行
└─ dispatch_end(有 shutdown 兜底)
3.2 后台完整流程
1. admin/index.php 定义 LITELOG_ROOT,require init.php
2. 加载 helpers.php
3. 处理 action=login / logout
4. checkLogin() 强制登录
5. ★ CSRF 全局校验(POST 请求)
6. Hook::doAction('admin_request', $action)
└─ 你的插件 admin 钩子在这里触发
7. PluginRegistry::renderAdminPage($action)(若注册过)
8. PluginRegistry::renderPluginSetting($name)(action=plugin_setting)
9. 内置路由 switch
第 2 部分:基础规范
第 4 章 · 目录结构与自动加载
4.1 标准目录结构
content/plugins/{插件名}/
├── {插件名}.php ← 【必须】主文件
├── {插件名}_callback.php ← 【可选】生命周期
├── {插件名}_setting.php ← 【可选】设置页
├── admin/
│ ├── register.php ← 【可选】后台注册
│ ├── pages/ ← 后台视图
│ └── assets/ ← 后台资源
├── frontend/
│ └── routes.php ← 【可选】前台路由
├── views/ ← 前台视图
├── assets/ ← 静态资源(CSS/JS)
└── data/ ← 私有数据(必须带 .htaccess)
4.2 ⭐ 内核自动加载真相
内核 loadActivePlugins() 只做三件事:
① require_once content/plugins/{name}/{name}.php ← 主文件
② if 存在 admin/register.php → require_once
③ if 存在 frontend/routes.php → require_once
| 文件 | 自动加载? | 说明 |
|---|---|---|
{插件名}.php | ✅ 是 | 唯一必须的文件 |
{插件名}_callback.php | ❌ 否 | 内核在启用/停用时主动 require |
{插件名}_setting.php | ❌ 否 | 通过 ?action=plugin_setting&name=xxx 访问 |
frontend/routes.php | ⚠️ 是(若放在约定路径) | 但推荐在主文件中显式 require,避免依赖内核细节 |
admin/register.php | ⚠️ 同上 | 同上 |
views/*.php | ❌ 否 | 由控制器/路由回调主动 include |
assets/* | ❌ 否 | 通过 Hook 注入 |
4.3 📌 口诀
"主文件自动,子文件自 load;不放约定路径,就要自己 require。"
第 5 章 · 插件主文件与头注释
5.1 标准头注释
<?php
/**
* Plugin Name: 我的插件
* Description: 一句话描述
* Version: 1.0.0
* Author: 你的名字
* Author URI: https://example.com
*/
if (!defined('LITELOG_ROOT')) exit('error!');
支持字段:Plugin Name / Name / Description / Version / Author / Author URI / Author URL
5.2 主文件的四件事
- 常量守卫:
if (!defined('XXX')) define('XXX', ...); - 定义工具函数:用
if (!function_exists(...))包裹 - 注册 Hook:
Hook::addAction(...)包在class_exists+try/catch中 - require 子文件:
frontend/routes.php/admin/register.php等
5.3 ⭐ PHP 语法安全写法
这是本轮修复战最痛的教训之一。
5.3.1 危险写法:连续两个 <?php
<?php if (!defined('LITELOG_ROOT')) exit('error!'); ?>
<?php
/* 大量代码 */
问题:只要用户在编辑器里粘贴时第二个 <?php 丢失,或被转义成 <?php(从富文本编辑器复制时经常发生),整个文件就会:
syntax error, unexpected '<', expecting end of file
导致整页白屏。
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'); ?>
规则:绝不出现"两个连续 PHP 块"。只在真正要输出 HTML 时才闭合 ?>。
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. 标题准备(三变量兜底,见第 20 章) */
$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()(需手动触发) | 同上 |
| 卸载插件 | callback_uninstall() | 同上 |
6.2 标准模板
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!defined('MYPLUGIN_VER')) define('MYPLUGIN_VER', '1.0.0');
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) {}
}
6.3 铁律
callback_init()整段 try/catch- DDL 无
COMMENT/COLLATE callback_rm()不删数据callback_uninstall()先删表 → 再清 option- 绝不在 callback 里输出任何内容
第 7 章 · 钩子系统
7.1 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通知型,返回值忽略;改数据用引用传参 - Filter:
applyFilters第一个参数是值,返回值替换
// Action 改数据
Hook::addAction('log_save_before', function (&$data, $gid) {
$data['title'] = '[置顶] ' . $data['title'];
});
// Filter 改数据
$data = Hook::applyFilters('log_save_before', $data, $gid);
7.2 常用钩子清单
生命周期
| 钩子 | 触发点 |
|---|---|
plugin_activate / plugin_deactivate / plugin_uninstall | 插件生命周期 |
布局锚点(推荐)
| 钩子 | 触发点 | 用途 |
|---|---|---|
layout_head_start | <head> 最前 | 早期 meta |
layout_head_end | </head> 前 | 加载 CSS |
layout_body_start | <body> 之后 | 顶部横幅 |
layout_content_start | <main> 之后 | 面包屑 |
layout_content_end | 内容后、</main> 前 | 相关文章 |
layout_footer_scripts | </body> 前的脚本区 | 加载 JS |
layout_body_end | </body> 之前 | 悬浮元素 |
分发路由
| 钩子 | 参数 | 用途 |
|---|---|---|
dispatch_start | — | 分发前 |
dispatch_plugin_route | $pluginName, $rest | 插件路由 |
dispatch_matched | &$matched | 路由匹配后 |
dispatch_end | — | 分发结束(shutdown 兜底) |
public_action_{action} | — | ?action=xxx 分发 |
内容与评论
| 钩子 | 参数 |
|---|---|
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 |
后台
| 钩子 | 参数 |
|---|---|
admin_request | $action |
admin_menu | $activeKey |
admin_head / admin_footer | — |
admin_dashboard_widgets | — |
扩展点
| 钩子 | 用途 |
|---|---|
register_widgets | 注册小工具 |
register_homepage_modes | 注册首页模式 |
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);
第 3 部分:核心能力
第 8 章 · PluginRegistry 注册表
8.1 API
| 方法 | 用途 |
|---|---|
addAdminPage($action, $config) | 注册后台页面 |
addAdminMenu($menu) | 注册侧边栏菜单 |
addFrontendRoute($regex, $cb) | 注册前台路由 |
addAdminAsset($action, $type, $url) | 注册后台资源 |
enqueueAsset($type, $url) | 手动暂存资源 |
renderAdminPage($action) | 渲染后台页面(内核调用) |
renderPluginSetting($name) | 渲染设置页(内核调用) |
8.2 注册后台页面
// admin/register.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
8.3 插件设置页
设置页放在 {插件名}_setting.php,通过核心入口访问:
<?= BLOG_URL ?>admin/?action=plugin_setting&name=xxx
内核做的事:
- 校验插件名合法
- 强制
Auth::requireCap('plugin.manage') - 渲染页面
- 自动包上 layout
设置页模板:
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!defined('IN_PLUGIN_SETTING')) {
http_response_code(404);
exit('Not Found');
}
if (!function_exists('MYPLUGIN_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>
8.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('ICG_isEnabled') || !ICG_isEnabled()) {
header('HTTP/1.0 404 Not Found');
if (class_exists('View')) View::display('404');
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: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX);
}
第 9 章 · 路由系统
9.1 三种路由方式
| 方式 | 触发 | 适用 |
|---|---|---|
dispatch_plugin_route 钩子 | 核心识别 /插件名/xxx | 推荐——简洁、自动 |
PluginRegistry::addFrontendRoute | 核心匹配正则 | 需要自定义 URL 模式 |
public_action_xxx 钩子 | ?action=xxx | 传统方式、兼容旧版本 |
9.2 ⭐ 核心 dispatcher 的伪静态识别
问题:核心默认不认识 /daily_article/detail/5 这种 URL,会 fallback 到 LogController::display()(显示博客首页),导致:
- 用户看到博客列表而非插件页面
- 浏览器标签显示站点名
- 插件资源钩子不生效
解决方案:给核心 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 !== '') {
$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正常触发)
9.3 插件端路由分发
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放最前- 找不到匹配时,设回 false,让核心继续处理
9.4 URL 生成函数
if (!function_exists('my_plugin_url')) {
function my_plugin_url($sub = '', $params = []) {
$useRewrite = (Option::get('url_rewrite', 'n') === 'y');
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] : [])
);
}
}
第 10 章 · 数据库与模型
10.1 Database 类精确签名
| 方法 | 返回值 |
|---|---|
query($sql, $params = []) | PDOStatement(可 ->rowCount()) |
fetchAll($sql, $params = []) | array(空时 []) |
fetchOne($sql, $params = []) | array \| false ⚠️ |
fetchColumn($sql, $params = []) | 标量 \ |
insert($table, $data) | lastInsertId 字符串(需 (int)) |
update($table, $data, $where, $whereParams = []) | true(不是行数) |
delete($table, $where, $params = []) | true |
10.2 ⭐ 三大陷阱
fetchOne()失败返回false,不是null
B26
insert()返回字符串lastInsertId,必须(int)$newId
update()/delete()返回true,要行数用query()->rowCount()
10.3 使用示例
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;
}
10.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 / {DB_PREFIX}tag / {DB_PREFIX}tag_blog
{DB_PREFIX}option
id(PK) / option_name(UNIQUE) / option_value(TEXT,永远是字符串) / autoload
Option::get() 返回永远是 string,判断用 === '1' / === 'y'。
10.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);
键名强制前缀 {插件名}_。
第 11 章 · 视图与模板
11.1 ⭐ 前台视图必须 include 主题 header/footer
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
/* 三变量兜底(见第 20 章) */
$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,它已经输出了骨架。
移动端优先:View::resolve() 会优先查找 templates/{主题}/mobile/ 子目录,找不到回退到普通目录。
11.2 🔴 前台评论表单必须加 CSRF
<form method="post" action="<?= BLOG_URL ?>?action=comment_post">
<?= Csrf::field() ?>
<input type="hidden" name="gid" value="<?= (int)$log['gid'] ?>">
<textarea name="comment" required></textarea>
<button type="submit">提交</button>
</form>
11.3 后台视图
后台视图由 PluginRegistry::addAdminPage 的 callback 调用,内核已包好 admin/views/layout.php,所以后台视图不要 include 主题 header/footer。
11.4 输出转义
<h1><?= h($log['title']) ?></h1>
<a href="<?= h($url) ?>"><?= h($name) ?></a>
11.5 后台可用核心 CSS 类
.card / .card-title / .btn / .btn-outline / .btn-danger / .btn-sm / .table-wrap / .table / .form / .field / .tag / .empty / .error / .pagination
第 12 章 · 资源加载
12.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';
12.2 兼容老主题
两个 Hook 都注册,用 static 去重:
Hook::addAction('layout_head_end', 'MP_injectCss');
Hook::addAction('template_head', 'MP_injectCss'); // 老主题兜底
12.3 后台资源
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');
12.4 数据目录保护
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>
第 13 章 · CSRF 与权限
13.1 CSRF 核心 API
$token = Csrf::token(); // 获取当前会话 token
echo Csrf::field(); // 输出隐藏字段
Csrf::verify(); // 校验(失败直接 403 并输出)
Csrf::verifyJson($payload); // 从 JSON body 校验
13.2 表单必须加 CSRF
任何 POST 表单必须:
<form method="post" action="...">
<?php if (class_exists('Csrf')) echo Csrf::field(); ?>
</form>
核心会在 admin/index.php 里对所有 POST 强制 Csrf::verify(),除非该 action 是通过 PluginRegistry::addAdminPage() 注册的插件页(由插件自行校验)。
13.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)
})
13.4 后台权限系统 Auth
Auth::check(); // 是否登录
Auth::id(); // 当前用户 uid
Auth::role(); // admin / editor / author / guest
Auth::can($cap); // 是否有某能力
Auth::requireCap($cap); // 强制校验
角色能力表:
| 角色 | 能力 |
|---|---|
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');
13.5 前台登录校验
前台不需要 Auth,用 isLoggedIn():
if (!isLoggedIn()) {
redirect(BLOG_URL . 'admin/?action=login');
}
第 14 章 · 小工具与首页模式
14.1 自定义小工具
class Widget_MyBlock extends Widget {
public $id = 'my_block';
public $name = '我的模块';
public $description = '展示自定义内容';
public $source = 'plugin:my_plugin'; // 分组用
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 依赖此属性)。
14.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());
});
第 4 部分:安全与陷阱
第 15 章 · 500 免疫
15.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 无 try/catch、带 COMMENT/COLLATE
关键三条:① 整段 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 被主文件遗忘
罪 8 🔴:PHP 标签混乱导致 Parse error
"连续两个 <?php"是陷阱,详见第 5.3 节。
15.2 📌 九条口诀
"define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require,PHP 标签别写乱。"
第 16 章 · 五条硬性安全规则
规则 1 · 文件操作,无 basename 不落盘
$safeName = basename($userInput);
if ($safeName === '' || $safeName !== $userInput) return false;
$safePath = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $safeName;
规则 2 · 数据库查询,无预处理不执行
// ✅
$row = $DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=?", [(int)$gid]);
// ❌
$DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=$gid");
规则 3 · 输出字符串必须转义
echo '<h1>' . h($log['title']) . '</h1>'; // ✅
echo '<h1>' . $log['title'] . '</h1>'; // ❌
规则 4 · CSRF 保护
见第 13 章。
规则 5 · ⭐ 所有表单/AJAX 的 URL 必须是绝对路径
禁止:action="?action=xxx"
必须:action="<?= BLOG_URL ?>?action=xxx"
详见第 19 章。
第 17 章 · 九条最高级别红线
- 🔴 严禁反序列化与动态执行——禁
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 写操作必须有登录校验
- 🔴 所有表单/AJAX 的 URL 必须是绝对路径
第 18 章 · 常见坑清单
18.1 安全
- 路径穿越——文件名过
basename() - SQL 注入——占位符绑定
- XSS——输出过
h() - IDOR——按 ID 操作必校验资源存在
- 上传——扩展名白名单 + 真实图片校验 + 随机文件名 + 像素上限
- 开放重定向——
redirect($url)只允许本站 URL - CSRF——所有 POST 请求带
_token或X-CSRF-Token - 权限——后台操作前
Auth::requireCap() - 相对 URL——所有表单 action 必须是绝对 URL
18.2 部署兼容
- 不硬编码路径——URL 拼
BLOG_URL,路径拼CONTENT_PATH/DIR - 子目录部署——假设站点可能在
https://example.com/blog/ - 大小写敏感——目录名小写下划线
- Cookie
litelog_view=mobile——PC 也可能走移动模板
18.3 结构规范
- 一目录一插件
- 不留临时文件
- 不改核心文件(除必要的 dispatcher 补丁)
- option 加插件名前缀
- 生命周期文件不输出内容
- UTF-8 无 BOM,LF 行尾,
php -l校验 - PHP 标签合并——避免连续两个
<?php
18.4 输出缓冲
- 不要用
ob_start('callback') - 回调必须判
is_string($html) - 长度守卫:处理后 < 原始 50% 时返回原始
ob_start回调内不能ob_start- 优先用
layout_*锚点 - 优先用
Output::capture()
18.5 兼容性
$DB是全局$data引用参数——log_save_before/comment_save_before必须function (&$data, ...)redirect()已自带 exit$_SESSION['uid']是登录标识fetchOne()失败返回falseinsert()返回字符串 lastInsertIdupdate()/delete()返回trueOption::get()第二参数永远''$_REQUEST受request_order影响——显式用$_POST/$_GET
18.6 标题相关
- 主题 header 可能读
$page_title/$title/$__pageTitle - 移动端 header 通常读
$__pageTitle - 三变量都要设置
- 主题 header 硬编码时用 JS 覆盖
document.title
第 5 部分:实战技巧
第 19 章 · ⭐ 相对 URL 陷阱(本轮修复战最痛的教训)
19.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) - 某些 Nginx 重写规则会先匹配"查看帖子 5",执行后
exit,删除请求根本没走到public_action_forum_delete - 用户点击删除无反应,或者刷新回同页面
19.2 🔴 铁律
所有表单的action必须使用绝对 URL:BLOG_URL . '?...'或插件的 URL 生成函数。
19.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') ?>">
19.4 JS 动态生成表单同样适用
// ❌ 反例
form.action = '?action=forum_thread_mod';
// ✅ 正例
form.action = '<?= BLOG_URL ?>?action=forum_thread_mod';
19.5 通用 POST 按钮生成器(推荐模板)
if (!function_exists('my_postBtn')) {
function my_postBtn($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>';
}
}
19.6 兜底:显式读 POST/GET
即使 URL 正确,也不要依赖 $_REQUEST(受 request_order 配置影响):
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
第 20 章 · ⭐ 页面标题三变量兜底
20.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 命中而走移动端模板。
20.2 🔴 铁律:视图顶部同时设置三个变量
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
/* ★ 三变量兜底 */
$page_title = '每日一文 - ' . Option::get('site_title', '');
$title = $page_title;
$__pageTitle = $page_title;
include View::resolve('header');
?>
$page_title:主流通用变量名$title:LiteLog 规范要求$__pageTitle:移动端模板专用
20.3 终极方案: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');
?>
20.4 为什么这样"绝对有效"
| 主题 header 读什么 | 效果 |
|---|---|
$page_title | ✅ 已经对 |
$title | ✅ 已经对 |
$__pageTitle | ✅ 已经对 |
| 硬编码站点名 | ✅ JS 覆盖 document.title |
| 什么都不输出 | ✅ 有 <title> 才覆盖 |
| 用了缓存/CDN | ✅ 只影响客户端显示,绕过服务端缓存 |
20.5 插件自己输出完整 HTML 骨架时
如果插件不走主题 header(如论坛插件),在 <title> 后立即注入 JS:
$siteTitle = Option::get('site_title', '');
$pageTitle = trim((string)$title);
if ($pageTitle === '') $pageTitle = '默认标题';
if ($siteTitle !== '' && mb_strpos($pageTitle, $siteTitle) === false) {
$pageTitle .= ' - ' . $siteTitle;
}
?>
<title><?= h($pageTitle) ?></title>
<script>
(function () {
var t = <?= json_encode($pageTitle, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
function setTitle() {
try { if (document.title !== t) document.title = t; } catch (e) {}
}
setTitle();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setTitle);
}
setTimeout(setTitle, 100);
window.addEventListener('load', function () { setTimeout(setTitle, 200); });
})();
</script>
第 21 章 · ⭐ 三点式日志探针法
21.1 适用场景
页面白屏,但错误日志里没有具体信息,或错误被吞掉。
21.2 原理
在关键位置插入只写日志、不输出的探针:
@file_put_contents(
CONTENT_PATH . 'backup/probe.log',
'[' . date('Y-m-d H:i:s') . '] 标记 ①' . "\n",
FILE_APPEND | LOCK_EX
);
不输出到页面是关键——避免探针本身触发新的错误。
21.3 三点布设
探针 ①:{插件名}.php 主文件
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
);
探针 ②:控制器方法开头
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
);
// ...
}
探针 ③:视图文件顶部
<?php
@file_put_contents(
CONTENT_PATH . 'backup/probe.log',
'[' . date('Y-m-d H:i:s') . '] ③ detail.php 开始执行' . "\n",
FILE_APPEND | LOCK_EX
);
?>
21.4 结果解读
| probe.log 内容 | 含义 | 下一步 |
|---|---|---|
| 文件不存在 | 请求根本没到插件 | dispatcher 路由没匹配 |
| 只有 ① | 主文件加载了,但控制器没被调用 | dispatch_plugin_route 没触发 |
| 有 ① + ② | 控制器进入了,但视图没执行 | 控制器内部崩了 |
| 有 ① + ② + ③ | 视图开始执行了,崩在视图内部 | 视图有语法或运行时错误 |
| 只有 ①,内容重复 20 次 | 插件被重复加载 | 有文件循环 require |
第 22 章 · ⭐ 移动端 router 抢跑
22.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() 根本不会执行,插件注册的所有钩子都不会生效。
22.2 影响
- 移动端访问插件页面 → 插件钩子没注册 → 404 或显示博客首页
- 前台资源注入(CSS / JS)不生效
- 插件路由(
dispatch_plugin_route)不触发
22.3 应对
方案 A:插件自己不依赖 mobile/router.php。所有路由都用 PluginRegistry::addFrontendRoute() 注册,它是 dispatch() 内部匹配的,不受 mobile router 影响。
方案 B:让 mobile/router.php 支持"未匹配时 fallthrough"(return 而不是 exit)。默认主题的 router 已经这么做了。
方案 C:核心 dispatcher 补丁(第 9.2 节),在 dispatch_start 后立即识别 /插件名/xxx,此时机在所有 router 之后。
第 23 章 · 输出缓冲与响应拦截
23.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 |
23.2 优先用 layout_* 锚点
99% 的插件不需要碰 ob_start。想往页面上放内容?用 layout_*:
if (class_exists('Hook')) {
try {
Hook::addAction('layout_body_start', function () {
echo '<div class="my-banner">欢迎访问</div>';
});
} catch (Throwable $e) { /* 写日志 */ }
}
23.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 '...';
});
第 6 部分:调试与诊断
第 24 章 · 错误排查方法论
24.1 核心原则
不要猜。要读。
24.2 症状 → 第一步
| 症状 | 第一步 |
|---|---|
| 后台 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 检查每个文件 |
24.3 打点方法
@file_put_contents(
CONTENT_PATH . 'backup/debug.log',
'[' . date('Y-m-d H:i:s') . '] ' . var_export($someVar, true) . "\n",
FILE_APPEND | LOCK_EX
);
24.4 常用错误日志路径
| 日志 | 内容 |
|---|---|
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 | 数据库查询错误 |
第 25 章 · 独立诊断脚本
25.1 适用场景
用户无法提供错误信息,页面白屏无提示时,放一个独立诊断脚本到根目录是最高效的定位方式。
25.2 完整模板
保存为 _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>
25.3 关键要点
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
25.4 输出解读
| 阶段 | 状态 | 含义 |
|---|---|---|
| 1 全部 OK | — | 核心加载正常 |
| 2 类/常量警告 | — | 可能版本不兼容 |
| 3 插件未启用 | err | 去后台启用 |
| 4 语法错误 | err | 视图/PHP 文件有语法错误 |
| 5 未含补丁 | warn | 伪静态路由不支持 |
| 6 有错误 | info | 从日志找到具体报错 |
第 26 章 · 常见错误诊断表
26.1 问题:浏览器标签显示站点名,不是插件标题
| 排查步骤 | 说明 |
|---|---|
1. 读主题 header.php | 看它 <title> 读什么变量 |
2. 读主题 mobile/header.php | 同上 |
| 3. 检查视图顶部 | 是否设置了 $page_title / $title / $__pageTitle |
4. 检查 Cookie litelog_view | 可能命中移动端 |
5. 加 JS 覆盖 document.title | 终极方案 |
26.2 问题:插件页面显示博客首页列表
| 排查步骤 | 说明 |
|---|---|
1. 检查 dispatcher.php | 是否含伪静态路由补丁 |
| 2. 检查 URL | 是否形如 /插件名/xxx |
| 3. 打补丁 | 见第 9.2 节 |
26.3 问题:删除/修改按钮点了没反应
| 排查步骤 | 说明 |
|---|---|
| 1. F12 Network 看请求 URL | 是不是 /xxx/yyy?action=... 而非 /?action=... |
| 2. 改绝对 URL | action="<?= BLOG_URL ?>?action=..." |
| 3. 检查 CSRF | 表单是否有 Csrf::field() |
| 4. 检查权限 | Auth::can() / Forum_checkPerm() |
26.4 问题:发帖/评论 403 "缺少 CSRF 令牌"
| 排查步骤 | 说明 |
|---|---|
| 1. 检查表单 | 是否有 <?= Csrf::field() ?> |
| 2. 检查 AJAX | 是否带 X-CSRF-Token header |
3. 检查 Csrf::verify() 时机 | 是否在 POST 处理前 |
26.5 问题:白屏 + 无错误信息
| 排查步骤 | 说明 |
|---|---|
1. 加 ini_set('display_errors', '1') | 临时打开错误显示 |
| 2. 运行诊断脚本 | 看具体阶段失败 |
3. php -l 检查每个 PHP 文件 | 语法错误 |
| 4. 三点式日志探针 | 定位执行到哪一步 |
| 5. 读错误日志 | content/backup/*.log |
第 7 部分:交付与质量
第 27 章 · 完整最小骨架
27.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
27.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
);
}
}
27.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() { /* 保留数据 */ }
function callback_uninstall() {
global $DB;
try { $DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "my_plugin_items`"); } catch (Throwable $e) {}
try { $DB->delete('option', "option_name LIKE 'my_plugin_%'"); } catch (Throwable $e) {}
}
27.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
);
}
27.5 views/main.php(★ 三变量兜底 + JS 覆盖)
<?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'); ?>
27.6 my_plugin_setting.php
见第 8.3 节。
27.7 assets/style.css / assets/script.js
空文件也必须存在(否则 filemtime() 会 warning)。
第 28 章 · 交付前自检清单
结构
- [ ] 目录名 = 主文件名(小写下划线)
- [ ] 有
{插件名}.php+{插件名}_callback.php - [ ] 有
frontend/routes.php(如需要) - [ ] 有
views/目录 - [ ] 有
assets/目录(哪怕空文件) - [ ] 有
{插件名}_setting.php(如需设置页)
安全
- [ ] 所有 SQL 用占位符
- [ ] 所有输出过
h() - [ ] 无文件操作,或过
basename() - [ ] AJAX 写操作有登录校验
- [ ] 原子扣减用
query()->rowCount() - [ ] 无
unserialize/eval - [ ]
IN (...)用array_map('intval')+ 占位符 - [ ] 未覆盖 PDO 属性
- [ ] 所有 POST 表单有
Csrf::field() - [ ] 所有 AJAX 带
X-CSRF-Token - [ ] 后台入口调
Auth::requireCap() - [ ] 所有表单 action 是绝对 URL
⭐ 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 - [ ] 每个
.php文件都通过php -l
⭐ 视图与资源
- [ ] 前台视图
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
⭐ 输出缓冲
- [ ] 没有使用
ob_start('callback')做页面拦截 - [ ] 如果必须用输出缓冲,用
Output::capture() - [ ] 如果注册了
dispatch_end,逻辑里有长度守卫 - [ ] 如果注册了
dispatch_end,逻辑里有非字符串输入处理
API 使用
- [ ]
$DB->fetchOne()判false - [ ]
$DB->insert()返回值(int)转换 - [ ] 需要行数用
query()->rowCount() - [ ] 用户名用
nickname ?: username - [ ] URL 拼接不重复加斜杠
- [ ] 函数/常量/表名有插件前缀
- [ ] AJAX 端点九条铁律全过
- [ ]
$_REQUEST换成显式$_POST/$_GET
验证
- [ ] 启用 → 访问 → 停用 → 再启用 → 卸载 全流程无异常
- [ ] 浏览器标签页标题正确
- [ ] F12 Console 无报错,Network 全 200
- [ ] 卸载后表删除、option 清空
- [ ] 用
_diag_plugin.php走查一遍
第 29 章 · 三十条铁律
| # | 教训 |
|---|---|
| 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 | 所有表单 action 必须是绝对 URL |
| 24 | JS 动态生成表单的 action 也必须是绝对 URL |
| 25 | 视图顶部同时设置 $page_title / $title / $__pageTitle |
| 26 | 主题 header 硬编码时,用 JS 强制覆盖 document.title |
| 27 | 连续两个 <?php 是陷阱——合并为一个大 PHP 块 |
| 28 | 诊断脚本必须先 define('LITELOG_ROOT', ...) |
| 29 | 移动端 router.php 会抢跑并 exit |
| 30 | $_REQUEST 受 request_order 影响——显式用 $_POST / $_GET |
常见错误推理链
❌ 错误链 A:路由不生效 → "可能 PluginRegistry 不存在" → 改用 Hook → 引入新问题
✅ 正确链:读 dispatcher.php → 发现路由在核心匹配 → 检查 PluginRegistry::matchFrontendRoute → 检查自己的注册代码 → 修复
❌ 错误链 B:CSRF 报错 → "可能 Csrf 类没加载" → 加 class_exists 判断 → 还是错
✅ 正确链:CSRF 报错 → 看报错是"缺少令牌"还是"不匹配" → "缺少"就检查表单/AJAX 是否带 token → "不匹配"就检查 session 是否丢了
❌ 错误链 C:删除按钮点了没反应 → "可能权限问题" → 加日志 → 还是没反应
✅ 正确链:查看 F12 Network → 表单提交到了哪个 URL → 如果是 /xxx/yyy?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 验证 → 修复
第 8 部分:附录
附录 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::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)
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)
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 · 系统钩子清单
生命周期
plugin_activate / plugin_deactivate / plugin_uninstall
前台布局
layout_head_start / layout_head_end / layout_body_start / layout_content_start / layout_content_end / layout_footer_scripts / layout_body_end
传统布局
template_head / body_start / header_before / header_after / before_content / after_content / footer_before / footer_after / template_footer / body_end
视图
view_before / view_after
分发
dispatch_start / dispatch_plugin_route / dispatch_matched / dispatch_end / public_action_{action}
内容
log_list_query / log_list_result / log_display_before / log_display_after / log_content / log_save_before / log_save_after / comment_save_before / comment_save_after / comment_section_before / comment_section_after
后台
admin_request / admin_menu / admin_head / admin_footer / admin_dashboard_widgets
扩展
register_widgets / register_homepage_modes
系统事件
user_login / user_logout / theme_activate / option_save
附录 D · 案例集
D.1 案例:相对 URL 陷阱导致删除按钮失效
症状:论坛插件点击"删除主题"按钮,页面刷新但帖子还在。
排查过程:
- F12 Network 看请求 URL:
POST /forum/thread/5?action=forum_delete&id=5 - 读
Forum_routeDelete()代码,逻辑正确 - 检查表单 HTML:
<form method="post" action="?action=forum_delete&id=5">← 相对 URL - 确认根因:浏览器把
?action=...解析成"当前路径 + query",被 Nginx 重写规则抢跑
修复:把 action="?..." 改为 action="<?= BLOG_URL ?>?..."。
教训:见第 19 章。
D.2 案例:标题不显示插件名
症状:访问 /daily_article/detail/5,浏览器标签显示站点名。
排查过程:
- 读主题
header.php,<title>读$page_title - 视图顶部设置了
$page_title—— 理论正确 - 用诊断脚本抓
View::resolve('header')的路径 —— 是mobile/header.php - Cookie
litelog_view=mobile导致浏览器被判定为移动端 - 移动端 header 读
$__pageTitle,没设置
修复:
- 视图顶部同时设置三变量
- 加 JS 强制覆盖
document.title(终极方案)
教训:见第 20 章。
D.3 案例:视图白屏无错误
症状:访问 /daily_article/detail/5,页面完全空白,无任何提示。
排查过程:
?__debug_errors=1打开错误显示 —— 仍空白- 加
ini_set('display_errors', 1)—— 仍空白 - 用诊断脚本 —— 阶段 4 显示:
B84
- 打开文件第 17 行 —— 发现连续两个
<?php
修复:合并为一个 PHP 块(见第 5.3 节)。
教训:编辑器粘贴容易破坏 PHP 标签,视图顶部结构要简化。
D.4 案例:插件路由 404
症状:访问 /daily_article/detail/5 显示博客首页列表。
排查过程:
- 检查
active_plugins——daily_article已启用 - 检查
daily_article.php—— 注册了dispatch_plugin_route - 读核心
dispatcher.php—— 发现只匹配__plugin_path和标准 Rewrite URL - 核心不认识
/daily_article/xxx格式 $uri = 'daily_article/detail/5'匹配不到任何前缀 → fallback 到LogController::display()
修复:给核心 dispatcher 打伪静态路由补丁(见第 9.2 节)。
教训:核心默认不支持任意插件名作为 URL 前缀,需要一次性补丁。
D.5 案例:AJAX 403 "缺少 CSRF 令牌"
症状:每日一文插件后台发文章,返回 403 错误。
排查过程:
- 阅读
admin/index.php,发现对所有 POST 请求强制执行Csrf::verify() - 检查插件表单 —— 没有
Csrf::field() - 每次 POST 都被拦截
修复:给所有 POST 表单加 <?= Csrf::field() ?>。
教训:见第 13.2 节。
附录 E · 给 AI 的指令模板
E.1 从零开发新插件
请按 LiteLog 插件开发手册 v4.0 开发一个 XXX 插件:
【附件:LiteLog 插件开发手册 v4.0 完整版.md】
需求:
- 功能:……
- 前台入口:URL ……,action=……
- 后台入口(可选):菜单名「……」,action=……,设置页放 `{插件名}_setting.php`
- 数据表:……
严格遵守本文件所有条款,特别是:
- 第 5.3 节「PHP 语法安全写法」
- 第 9 章「路由系统」
- 第 15 章「500 免疫」
- 第 16 章「五条硬性安全规则」(尤其规则 5 绝对 URL)
- 第 19 章「相对 URL 陷阱」
- 第 20 章「标题三变量兜底」
- 第 27 章「完整最小骨架」
- 第 28 章「交付前自检清单」
不要修改任何核心文件(除非明确需要 dispatcher 补丁)。完成后给出完整文件列表和每个文件的完整代码。
E.2 排查 500 错误
我的插件启用后后台 500。请根据手册第 15 章排查。
【附件:LiteLog 插件开发手册 v4.0 完整版.md】
报错信息:……
E.3 排查 CSRF 错误
我的插件出现 CSRF 校验失败:……
【附件:LiteLog 插件开发手册 v4.0 完整版.md】
当前代码:(贴表单 / AJAX / 端点)
日志:(content/backup/plugin-error.log)
请按第 13 章排查。
E.4 排查"按钮点了没反应"
我的插件里有一个删除按钮,点击后页面刷新了但目标没被删除。
【附件:LiteLog 插件开发手册 v4.0 完整版.md】
当前表单代码:……
浏览器 Network 面板显示的请求 URL:……
请按第 19 章「相对 URL 陷阱」排查。
E.5 排查"标题不对"
我的插件页面浏览器标签始终显示站点名,不是插件标题。
【附件:LiteLog 插件开发手册 v4.0 完整版.md】
主题 header.php 的 <title> 那行代码:……
插件视图顶部代码:……
请按第 20 章排查。
E.6 排错通用模板
我的插件出现以下症状:……
【附件:LiteLog 插件开发手册 v4.0 完整版.md】
当前代码:(贴主文件 + 视图 + 路由 + 设置页)
内核相关信息:(贴 dispatcher.php / 主题 header.php)
日志:(content/backup/plugin-error.log)
请按以下顺序排查:
1. 先读内核源码确认机制(不要猜)
2. 再检查代码是否符合机制
3. 给出最小修改方案
4. 给出验证方法
如果需要诊断脚本,请直接给我一个完整的 `_diag_plugin.php`。
附录 F · 更新日志
v4.0(2026-10-13)
新增:
- 第 5.3 节 PHP 语法安全写法
- 第 9.2 节 核心 dispatcher 伪静态路由补丁
- 第 19 章 相对 URL 陷阱
- 第 20 章 页面标题三变量兜底
- 第 21 章 三点式日志探针法
- 第 22 章 移动端 router 抢跑
- 第 25 章 独立诊断脚本
- 第 26 章 常见错误诊断表
- 附录 D 案例集(5 个真实案例)
改进:
- 手册整体结构重构为 8 部分
- 加入"阅读指引"
- 铁律从 22 条扩到 30 条
- 增加"常见错误推理链"
- 附录新增 API 速查表
v3.2(2026-10-13)
在 v3.1 基础上补充相对 URL、三变量兜底、伪静态路由、PHP 标签、诊断脚本、三点式探针、移动端 router 抢跑等经验。
v3.1(2026-10-12)
在 v3.0 基础上新增 CSRF 全局防护、后台权限系统 Auth、前台评论 CSRF、插件设置页统一入口、AJAX 九条铁律、会话安全强化。
v3.0(2026-10-11)
基础版本,包含 500 免疫、三条硬性安全规则、八条红线、目录结构、钩子系统、数据库、视图、资源、调试、骨架、自检清单、18 条铁律。
收尾:一句话总结
📌 "define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require,PHP 标签别写乱。"
核心原则 16 条
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 ⑩ 标题三变量兜底 + 必要时 JS 覆盖document.title⑪ PHP 标签合并,避免连续两个<?php⑫ 诊断脚本必须先define('LITELOG_ROOT')⑬ 三点式日志探针法定位白屏崩在哪一步
v4.0 三条:
⑭ 核心 dispatcher 需打伪静态路由补丁,让所有插件自动支持/插件名/xxx⑮ 移动端 router 会抢跑并exit——init.php里它在loadActivePlugins()之前 ⑯$_REQUEST换成显式$_POST/$_GET,不受request_order影响
掌握这 16 条 + 五条硬性规则 + 九条红线 + 30 条铁律,99% 的插件能一次跑通。
本手册基于 LiteLog 内核 v1.2 + default 主题 v1.3.1 + 每日一文/论坛插件两轮完整修复战整理。
v4.0 相对 v3.2 的主要新增:
- 手册结构重构为 8 部分
- 相对 URL 陷阱独立成章
- 标题三变量兜底独立成章
- 三点式日志探针法独立成章
- 独立诊断脚本独立成章
- 常见错误诊断表
- 5 个真实案例集
- API 速查表
- 16 条核心原则汇总
若内核继续更新,请同步更新第 9 章「路由系统」、第 10 章「数据库」、第 13 章「CSRF 与权限」、第 17 章「红线」、第 29 章「三十条铁律」五个章节。