LiteLog 插件开发规范 v3.1
版本:3.1(2026-10-12) 性质:基于 v3.0 规范 同步内核最新改动(CSRF、Auth 权限、Session 安全、上传加固等)逐行修订。 v3.1 相对 v3.0 的核心新增: - ① CSRF 全局防护——所有 POST 请求默认校验,插件必须带Csrf::field()或X-CSRF-Token(第 2.4 节) - ② 后台权限系统 Auth——Auth::can()/Auth::requireCap()取代裸isLoggedIn()(第 2.5 节) - ③ 前台评论表单必须加 CSRF token(第 10 章) - ④ 插件设置页统一入口?action=plugin_setting&name=xxx(第 7 章) - ⑤ 后台 AJAX 九条铁律(v3.0 的八条 + CSRF 一条) - ⑥ 会话安全强化:session_regenerate_id、HttpOnly、SameSite=Lax(第 13 章) - ⑦ 上传端点加固:像素上限、CSRF 校验(第 15 章) - ⑧ 第 19 章铁律从 18 条扩到 22 条 核实基准:LiteLog 内核 v1.2(2026-10-12)+ default 主题 v1.3.0 给 AI 下指令时,请把本文件完整附上。
📌 给 AI 的必读(0 号指令)
写代码前,请按顺序读完以下章节:
- 第 1 章《500 免疫》 —— 违反任意一条,插件启用后后台直接 500。
- 第 2 章《四条硬性安全规则》 —— ★ v3.1 从三条扩为四条,新增 CSRF。
- 第 2.5 节《后台权限系统》 —— ★ v3.1 新增。
- 第 3 章《八条最高级别红线》 —— 一票否决。
- 第 4 章《目录结构与自动加载真相》。
- 第 8.5 节《输出缓冲与响应拦截》 ——
ob_start陷阱。 - 第 10 章《视图与模板》 —— 前台视图必须 include 主题 header/footer;评论表单必须加 CSRF。
- 第 11 章《资源加载》 —— CSS/JS 走
layout_*Hook 注入。 - ★ 第 14.4 节《AJAX 端点九条铁律》 —— v3.1 新增 CSRF 一条。
目录
- 第 1 章:500 错误免疫
- 第 2 章:四条硬性安全规则
- 2.4 CSRF 保护
- 2.5 后台权限系统
- 第 3 章:八条最高级别红线
- 第 4 章:目录结构与自动加载真相
- 第 5 章:插件主文件与头注释
- 第 6 章:生命周期
- 第 7 章:PluginRegistry 注册表
- 第 8 章:钩子系统全览
- 第 8.5 章:输出缓冲与响应拦截
- 第 9 章:数据库与模型
- 第 10 章:视图与模板
- 第 11 章:资源加载
- 第 12 章:小工具与首页模式扩展
- 第 13 章:加载时机 / exit 边界 / 命名约定
- 第 14 章:API 参考
- 第 15 章:常见坑清单
- 第 16 章:调试方法
- 第 17 章:完整最小骨架
- 第 18 章:交付前自检清单
- 第 19 章:开发经验教训(22 条铁律)
- 第 20 章:给 AI 的指令模板
第 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。
口诀:callback 整段包,DDL 去 COMMENT。
罪 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
if (class_exists('Hook')) {
try {
Hook::addAction('layout_body_start', 'ICG_injectTop');
} catch (Throwable $e) {
@file_put_contents(CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin hook register: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX);
}
}
罪 6 ★★★:PluginRegistry 相关调用无 class_exists 守卫
罪 7 ★★★:frontend/routes.php / admin/register.php 被主文件遗忘
内核 loadActivePlugins() 只加载 {插件名}.php,子文件必须主文件主动 require:
if (class_exists('PluginRegistry')) {
try {
$__routes = __DIR__ . '/frontend/routes.php';
if (is_file($__routes)) require_once $__routes;
} catch (Throwable $e) { /* 写日志 */ }
}
1.2 八条口诀汇总
「define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require。」
第 2 章:四条硬性安全规则(v3.1 扩充)
规则 1 — 文件操作,无 basename 不落盘
$safeName = basename($userInput);
if ($safeName === '' || $safeName !== $userInput) return false;
$safePath = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $safeName;
if (is_file($safePath)) unlink($safePath);
规则 2 — 数据库查询,无预处理不执行
// ✅
$row = $DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=?", [(int)$gid]);
// ✅ IN 查询
$ids = array_map('intval', $ids);
if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?'));
$rows = $DB->fetchAll("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid` IN ($in)", $ids);
}
// ❌
$DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=$gid");
规则 3 — 输出字符串必须转义
echo '<h1>' . h($log['title']) . '</h1>'; // ✅
echo '<h1>' . $log['title'] . '</h1>'; // ❌
2.4 CSRF 保护(★ v3.1 新增)
本节是 v3.1 最重要的新增。 内核从 v1.2 起,所有后台 POST 请求、前台评论提交都强制校验 CSRF token,插件必须配合。
2.4.1 核心 API
// 获取当前会话 token
$token = Csrf::token();
// 输出隐藏字段(表单用)
echo Csrf::field(); // <input type="hidden" name="_token" value="...">
// 校验(失败直接 403 并输出,不返回)
Csrf::verify();
// 从 JSON body 校验(AJAX 用)
Csrf::verifyJson($payload);
2.4.2 表单必须加 CSRF token
任何 POST 表单(前台评论、后台插件设置、自定义表单)必须:
<form method="post" action="...">
<?php if (class_exists('Csrf')) echo Csrf::field(); ?>
<!-- 其他字段 -->
</form>
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 拦截(全局只拦 admin/index.php 内的 POST)。但推荐插件也校验:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (class_exists('Csrf')) {
Csrf::verify();
}
}
插件后台页面(通过 PluginRegistry::addAdminPage() 注册)跳过了核心全局 CSRF 拦截,由插件自行决定:
// 在插件页面的 POST 处理里
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>
如果你的主题还没加,所有评论提交都会失败(返回 {"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 后台权限系统(★ v3.1 新增)
内核 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(); // 登录失败是否锁定
Auth::bumpLoginFail(); // 记录一次失败
Auth::clearLoginFail(); // 清空失败计数
2.5.2 角色能力表
| 角色 | 能力 |
|---|---|
admin | *(全部) |
editor | log. / comment. / sort. / tag. / widget.manage / theme.manage / plugin.manage |
author | log.own / comment.reply |
关键能力名:
log.manage/log.owncomment.manage/comment.replysort.managetheme.manageplugin.managewidget.manageoption.manage
2.5.3 插件后台入口的权限写法
// admin/register.php
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) { /* 写日志 */ }
}
或更细粒度:
if (!Auth::can('option.manage')) {
http_response_code(403);
exit('无权限');
}
2.5.4 前台登录校验(评论、点赞等)
前台不需要 Auth,用 isLoggedIn() 即可:
if (!isLoggedIn()) {
// 未登录处理
}
2.5.5 会话安全
内核 v1.2 已强化 session:
session_regenerate_id(true)在登录后调用HttpOnly= trueSameSite= LaxSecure根据 HTTPS 自动设置use_strict_mode= 1
插件不需要做任何事,直接使用 $_SESSION 即可。
第 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 属性不得覆盖 —— 不得改
ATTR_ERRMODE/ATTR_EMULATE_PREPARES - AJAX 写操作必须有登录校验 ——
if (empty($_SESSION['uid']))直接返回 JSON
v3.1 补充:红线 8 扩展为「登录 + CSRF 双重校验」(见第 14.4 节九条铁律)。
第 4 章:目录结构与自动加载真相
4.1 目录结构
content/plugins/{插件名}/
├── {插件名}.php ← 【必须】主文件
├── {插件名}_callback.php ← 【可选】生命周期
├── {插件名}_setting.php ← 【可选】设置页(v3.1 推荐:?action=plugin_setting 访问)
├── admin/
│ ├── register.php ← 【可选】后台注册(需主文件 require)
│ ├── pages/ ← 后台视图
│ └── assets/ ← 后台资源
├── frontend/
│ └── routes.php ← 【可选】前台路由(需主文件 require)
├── 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 或视图引用 |
第 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 主文件只做四件事
- 常量守卫(
define+function) - 定义工具函数
- 注册 Hook(
Hook::addAction) require子文件
禁止:顶层 echo / print / header / DB 写入 / 耗时 IO。
第 6 章:生命周期(callback 回调)
不是activate.php/deactivate.php,而是{插件名}_callback.php。
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) {}
}
6.3 约定
callback_init()必须整段 try/catch- DDL 无
COMMENT/COLLATE callback_rm()不删数据callback_uninstall()先删表 → 再清 option
第 7 章:PluginRegistry 注册表
7.1 完整 API(v3.1 更新)
| 方法 | 签名 | 用途 |
|---|---|---|
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) | ★ v3.1 新增:渲染 {插件名}_setting.php,自动鉴权 |
7.2 ★ v3.1 新增:插件设置页统一入口
v3.0 之前插件设置页直接暴露 URL:
<?= BLOG_URL ?>content/plugins/xxx/xxx_setting.php
问题:任何访客都能直接访问,插件作者常忘加登录校验。
v3.1 修复:设置页放在 {插件名}_setting.php,通过核心入口访问:
<?= BLOG_URL ?>admin/?action=plugin_setting&name=xxx
内核做的事:
- 校验插件名合法
- 强制
Auth::requireCap('plugin.manage') - 渲染页面
- 自动包上 layout
插件不需要改任何代码,只要文件放在 content/plugins/{插件名}/{插件名}_setting.php 即可。
后台插件卡片会自动显示「设置」按钮(admin/views/plugins.php 里判断文件存在)。
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);
}
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 () {
// ★ v3.1:必须校验权限
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 章:钩子系统全览(内核实测)
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
// Action 改数据
Hook::addAction('log_save_before', function (&$data, $gid) {
$data['title'] = '[置顶] ' . $data['title'];
});
// Filter 改数据
$data = Hook::applyFilters('log_save_before', $data, $gid);
8.2 生命周期钩子
| 钩子名 | 触发点 | 参数 |
|---|---|---|
plugin_activate | 插件启用后 | $name |
plugin_deactivate | 插件停用后 | $name |
plugin_uninstall | 插件卸载 | $name |
8.3 前台模板钩子
主题布局锚点 layout_*(v3.0 起)
| 钩子名 | 触发点 | 推荐用途 |
|---|---|---|
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 / 悬浮按钮 |
视图钩子(v3.0 起)
| 钩子名 | 触发点 | 参数 |
|---|---|---|
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 |
public_action_xxx | ?action=xxx 分发前 | 无 |
★ v3.1 关键点:dispatch_end有 shutdown 兜底,无论哪里exit都会触发一次。但**布局注入仍推荐layout_***。
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 扩展点钩子
| 钩子名 | 说明 |
|---|---|
register_widgets | 注册自定义小工具 |
register_homepage_modes | 注册首页模式 |
admin_request / admin_head / admin_footer / admin_menu / admin_dashboard_widgets | 后台各处 |
user_login / user_logout / theme_activate / option_save | 系统事件 |
8.6 钩子注册规范
- 所有
Hook::addAction()包在class_exists('Hook')+try/catch - 插件自己触发供其它插件监听的钩子必须加插件名前缀
第 8.5 章:★ 输出缓冲与响应拦截
90% 的 DIY 插件 bug 都在这一章。
8.5.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.5.2 ★ v3.1 推荐:优先用 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.5.3 只在必须时用 Output::capture()
唯一推荐写法(要拦截整个页面时):
Hook::addAction('dispatch_start', function () {
ob_start();
});
Hook::addAction('dispatch_end', function () {
if (ob_get_level() <= 0) return;
$html = ob_get_clean();
if (!is_string($html) || $html === '') return;
$extra = Output::capture(function () {
// ... 输出内容 ...
});
$new = $html . $extra;
// 长度守卫:处理后 < 原始 50% 时返回原始
if (strlen($html) > 200 && strlen($new) < strlen($html) * 0.5) {
echo $html;
return;
}
echo $new;
});
8.5.4 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 '...';
});
Output::level(); // 当前 ob 层级
Output::debug(); // ['ob_level' => N, 'named' => [...]]
Output::discard('my_region');
第 9 章:数据库与模型
9.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 |
三大陷阱:
fetchOne()失败返回false(不是null) —— 用if ($row === false)insert()返回字符串 lastInsertId —— 必须(int)$newIdupdate()/delete()返回true—— 要行数用query()->rowCount()
原子扣减(红线 5)
$stmt = $DB->query(
"UPDATE `" . DB_PREFIX . "user_profile` SET `credit`=`credit`-1 WHERE `uid`=? AND `credit`>0",
[(int)$uid]
);
if ($stmt->rowCount() === 0) {
return ['ok' => false, 'msg' => '余额不足'];
}
9.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
★ v3.1 变化:top字段从enum('n','y')改为TINYINT(1) NOT NULL DEFAULT 0。写1表示置顶,0表示普通。
{DB_PREFIX}comment
cid(PK) / gid / pid / poster / email / url / comment / ip / agent / date / hide('y'待审/'n'通过)
{DB_PREFIX}sort
sid(PK) / sortname / alias / pid / description
{DB_PREFIX}tag / {DB_PREFIX}tag_blog
tag: tid(PK) / tagname;tag_blog: gid / tid
{DB_PREFIX}option
id(PK) / option_name(UNIQUE) / option_value(TEXT,永远是字符串) / autoload('y'/'n')
★ 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);
键名强制前缀 {插件名}_。
第 10 章:视图与模板
10.1 ★ 前台视图必须 include 主题 header/footer
<!-- ✅ views/generator.php -->
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
$page_title = '图标生成器 - ' . Option::get('site_title', '');
$title = $page_title;
include View::resolve('header');
?>
<div class="my-container">
... 页面内容 ...
</div>
<?php include View::resolve('footer'); ?>
原理:View::resolve('header') 解析到当前主题的 header.php,它已经输出了骨架。
10.2 ★ v3.1 新增:前台评论表单必须加 CSRF
如果你的插件提供评论相关表单(如自定义评论模板),必须加 CSRF token:
<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;
include View::resolve('header');
?>
<!-- 内容 -->
<?php include View::resolve('footer'); ?>
10.4 后台视图
后台视图由 PluginRegistry::addAdminPage 的 callback 调用,内核已包好 admin/views/layout.php,所以后台视图不要 include 主题 header/footer:
<?php
// admin/pages/settings.php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!function_exists('MP_isEnabled')) { echo '插件未加载'; return; }
?>
<h2>设置</h2>
<div class="card">...</div>
10.5 输出转义
<h1><?= h($log['title']) ?></h1>
<a href="<?= h($url) ?>"><?= h($name) ?></a>
10.6 后台可用核心 CSS 类
.card / .card-title / .btn / .btn-outline / .btn-danger / .btn-sm / .table-wrap / .table / .form / .field / .tag / .empty / .error / .pagination
10.7 常用辅助函数
| 函数 | 说明 |
|---|---|
h($s) | HTML 转义 |
redirect($url) | 302 跳转 + exit(自带) |
isLoggedIn() | 是否登录 |
checkLogin() | 强制登录(后台用) |
dateFmt($ts) / datetimeFmt($ts) | 日期格式化 |
getExcerpt($html, $len) | 摘要 |
makeAlias($str) | URL 别名 |
pagination($page, $total, $perPage) | 分页 HTML |
10.8 URL 生成
Url::log($gid, $alias); // 文章
Url::sort($sid, $alias); // 分类
Url::tag($name); // 标签
Url::author($uid); // 作者
Url::record('2026-10'); // 归档
禁止硬编码 '/post/1.html'。
第 11 章:资源加载
11.1 ★ v3.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 后台资源
// admin/register.php
PluginRegistry::addAdminPage('my_plugin', [
'title' => '我的插件',
'callback' => function () { include __DIR__ . '/pages/main.php'; },
]);
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');
内核会自动把资源暂存到 $pendingAssets,layout.php 会在 <head> 和 </body> 前正确输出。
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 自定义小工具
class Widget_MyBlock extends Widget {
public $id = 'my_block';
public $name = '我的模块';
public $description = '展示自定义内容';
public function defaults() {
return ['title' => '默认标题', 'count' => 5];
}
public function form($settings) {
$s = array_merge($this->defaults(), $settings ?: []);
$this->fieldText('title', $s['title'], '标题');
$this->fieldNumber('count', $s['count'], '显示数量', 1, 20);
}
public function render($settings, $instanceId) {
$s = array_merge($this->defaults(), $settings ?: []);
ob_start();
echo '<ul>...</ul>';
$this->output($instanceId, $s['title'], ob_get_clean());
}
}
Hook::addAction('register_widgets', function () {
WidgetRegistry::register(new Widget_MyBlock());
});
内置基类方法:fieldText / fieldNumber / fieldSelect / output
表单字段必须带 data-setting="键名"(内核 widgets.js 依赖此属性)。
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 → 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 ★ v3.1 新增:会话安全
内核 v1.2 已强化:
| 项 | 值 |
|---|---|
session_regenerate_id(true) | 登录成功后调用 |
Cookie HttpOnly | true |
Cookie SameSite | Lax |
Cookie Secure | HTTPS 自动开启 |
use_strict_mode | 1 |
| Session 名 | LITELOG_SID |
插件不需要做任何事,直接用 $_SESSION 即可。
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/...';
第 14 章:API 参考(实测)
14.1-14.3 见第 9 章 / 第 10 章。
14.4 ★ AJAX 端点九条铁律(v3.1 扩充)
- 第一行
header('Content-Type: application/json; charset=utf-8') - 登录校验(红线 8)
- ★ v3.1 新增:CSRF 校验
- 方法校验(写操作拒绝
GET) - 参数强转 + 范围校验
- DB 操作整段
try/catch - 事务内先
inTransaction()再rollBack() - 异常不逃逸(catch 里必须
echo json_encode(...)再exit) exit收尾
标准 AJAX 端点模板(v3.1)
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 校验(v3.1 新增)
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 {
$row = $DB->fetchOne(
"SELECT id, owner_id FROM `" . DB_PREFIX . "my_items` WHERE id = ?",
[$itemId]
);
if ($row === false) {
echo json_encode(['ok' => false, 'msg' => '目标不存在'], JSON_UNESCAPED_UNICODE);
exit;
}
$pdo = $DB->pdo();
$pdo->beginTransaction();
$stmt = $DB->query(
"UPDATE `" . DB_PREFIX . "user_profile` SET credit = credit - ? WHERE uid = ? AND credit >= ?",
[$points, $uid, $points]
);
if ($stmt->rowCount() === 0) {
$pdo->rollBack();
echo json_encode(['ok' => false, 'msg' => '余额不足'], JSON_UNESCAPED_UNICODE);
exit;
}
$DB->query(
"UPDATE `" . DB_PREFIX . "user_profile` SET credit = credit + ? WHERE uid = ?",
[$points, (int)$row['owner_id']]
);
$pdo->commit();
echo json_encode(['ok' => true, 'msg' => '成功'], JSON_UNESCAPED_UNICODE);
exit;
} catch (Throwable $e) {
try {
if ($pdo && $pdo->inTransaction()) $pdo->rollBack();
} catch (Throwable $ignored) {}
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] MP_handleAjax: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
echo json_encode(['ok' => false, 'msg' => '服务器错误'], JSON_UNESCAPED_UNICODE);
exit;
}
}
}
前端调用
后台页面(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 })
})
前台页面(layout.php 不参与,需要手动加 token):
// 从页面某处读取 token(比如 <meta> 或隐藏 input)
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 })
})
前台页面需要在 header.php 里注入 token meta(主题需支持):
<meta name="csrf-token" content="<?= h(Csrf::token()) ?>">
第 15 章:常见坑清单
15.1 安全
- 路径穿越 —— 文件名过
basename() - SQL 注入 —— 占位符绑定
- XSS —— 输出过
h() - IDOR —— 按 ID 操作必校验资源存在
- 上传 —— 扩展名白名单 + 真实图片校验 + 随机文件名 + 像素上限
- 开放重定向 ——
redirect($url)只允许本站 URL - ★ v3.1:CSRF —— 所有 POST 请求带
_token或X-CSRF-Token - ★ v3.1:权限 —— 后台操作前
Auth::requireCap()
15.2 部署兼容
- 不硬编码路径 —— URL 拼
BLOG_URL,路径拼CONTENT_PATH/DIR - 子目录部署 —— 假设站点可能在
https://example.com/blog/ - 大小写敏感 —— 目录名小写下划线
15.3 结构规范
- 一目录一插件
- 不留临时文件
- 不改核心文件
- option 加插件名前缀
- 生命周期文件不输出内容
- UTF-8 无 BOM,LF 行尾,
php -l校验
15.4 输出缓冲陷阱
- 不要用
ob_start('callback') - 回调必须判
is_string($html),非字符串返回'' - 长度守卫:处理后 < 原始 50% 时返回原始
ob_start回调内不能ob_start- **优先用
layout_*锚点** - 优先用
Output::capture()
15.5 兼容性
$DB是全局 —— 函数里用必须global $DB;$data引用参数 ——log_save_before/comment_save_before必须function (&$data, ...)redirect()已自带 exit$_SESSION['uid']是登录标识fetchOne()失败返回falseinsert()返回字符串 lastInsertId —— 必须(int)update()/delete()返回true- ★ v3.1:
Option::get()第二参数永远''—— 不要传null
第 16 章:调试方法
16.1 遇到"行为不符预期"时的第一反应
不要猜。要读。
| 症状 | 第一步 |
|---|---|
| 后台 500 | 打开 display_errors,看报错行号 |
| 前台白屏 | F12 Console 看 JS;Network 看 HTML |
| 路由不生效 | 读 dispatcher.php |
| 标题不对 | 读主题 header.php |
| CSS/JS 没加载 | F12 Network 看状态码 |
| CSRF 校验失败 | 检查表单是否有 Csrf::field();AJAX 是否带 X-CSRF-Token |
| 权限拒绝 | 检查 Auth::can() / Auth::requireCap() 的能力名 |
| DIY/拦截类白屏 | 读 content/backup/diy-debug.log |
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 过期,刷新页面重试 |
打点方法:
@file_put_contents(
CONTENT_PATH . 'backup/csrf-debug.log',
'[' . date('Y-m-d H:i:s') . '] method=' . $_SERVER['REQUEST_METHOD']
. ' has_post_token=' . (isset($_POST['_token']) ? 'y' : 'n')
. ' has_header=' . (isset($_SERVER['HTTP_X_CSRF_TOKEN']) ? 'y' : 'n')
. ' session_token=' . substr($_SESSION['_csrf_token'] ?? '', 0, 8)
. "\n",
FILE_APPEND | LOCK_EX
);
第 17 章:完整最小骨架(可直接抄写)
17.1 目录结构
content/plugins/my_plugin/
├── my_plugin.php
├── my_plugin_callback.php
├── my_plugin_setting.php ← 可选,走 ?action=plugin_setting 访问
├── 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';
}
}
/* ---------- 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() { /* 保留数据 */ }
function callback_uninstall() {
global $DB;
try { $DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "my_plugin_items`"); } catch (Throwable $e) {}
try { $DB->delete('option', "option_name LIKE 'my_plugin_%'"); } catch (Throwable $e) {}
}
17.4 frontend/routes.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;
try {
PluginRegistry::addFrontendRoute('my_plugin/?', function ($matches) {
if (!function_exists('MP_isEnabled') || !MP_isEnabled()) {
header('HTTP/1.0 404 Not Found');
if (class_exists('View')) View::display('404');
return;
}
$GLOBALS['__mp_active'] = true;
include __DIR__ . '/../views/main.php';
});
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin route register: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
17.5 views/main.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
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;
include View::resolve('header');
?>
<div class="mp-container">
<h1>我的插件</h1>
<!-- 示例:带 CSRF 的表单 -->
<form method="post" action="">
<?= 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 (!function_exists('MP_isEnabled')) { echo '插件未加载'; return; }
// ★ 内核已校验权限(Auth::requireCap('plugin.manage'))
// 这里只处理业务逻辑
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 章:交付前自检清单
结构
- [ ] 目录名 = 主文件名(小写下划线)
- [ ] 有
{插件名}.php+{插件名}_callback.php - [ ] 有
frontend/routes.php(如需要) - [ ] 有
views/目录 - [ ] 有
assets/目录(哪怕空文件) - [ ] 有
{插件名}_setting.php(如需设置页,走?action=plugin_setting访问)
安全
- [ ] 所有 SQL 用占位符
- [ ] 所有输出过
h() - [ ] 无文件操作,或过
basename() - [ ] AJAX 写操作有登录校验
- [ ] 原子扣减用
query()->rowCount() - [ ] 无
unserialize/eval - [ ]
IN (...)用array_map('intval')+ 占位符 - [ ] 未覆盖 PDO 属性
- [ ] ★ v3.1:所有 POST 表单有
Csrf::field() - [ ] ★ v3.1:所有 AJAX 带
X-CSRF-Token - [ ] ★ v3.1:后台入口调
Auth::requireCap()
⭐ 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
⭐ 视图与资源
- [ ] 前台视图
include View::resolve('header')和footer - [ ] 前台视图设置
$page_title(和$title) - [ ] 前台视图不自建
<!DOCTYPE html> - [ ] CSS/JS 走
layout_head_end/layout_footer_scriptsHook 注入 - [ ] 资源 URL 拼
BLOG_URL,带?v=filemtime
⭐ 输出缓冲
- [ ] 没有使用
ob_start('callback')做页面拦截 - [ ] 如果必须用输出缓冲,用
Output::capture() - [ ] 如果注册了
dispatch_end,逻辑里有长度守卫 - [ ] 如果注册了
dispatch_end,逻辑里有非字符串输入处理
API 使用
- [ ]
$DB->fetchOne()判false - [ ]
$DB->insert()返回值(int)转换 - [ ] 需要行数用
query()->rowCount() - [ ] 用户名用
nickname ?: username - [ ] URL 拼接不重复加斜杠
- [ ] 函数/常量/表名有插件前缀
- [ ] AJAX 端点九条铁律全过
验证
- [ ]
php -l每个.php文件都过 - [ ] 启用 → 访问 → 停用 → 再启用 → 卸载 全流程无异常
- [ ] 浏览器标签页标题正确
- [ ] F12 Console 无报错,Network 全 200
- [ ] 卸载后表删除、option 清空
第 19 章:开发经验教训(实测总结)
v3.1 扩充:从 18 条扩到 22 条,加入 CSRF 和权限相关教训。
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 | ★ v3.1:所有 POST 请求默认被 CSRF 拦截,除非显式跳过——表单没带 _token、AJAX 没带 X-CSRF-Token,一定 403 |
| 20 | ★ v3.1:插件后台页面跳过核心全局 CSRF,需自行校验——见 admin/index.php 的 $isPluginPage 判断 |
| 21 | ★ v3.1:后台操作前调 Auth::requireCap(),不要只用 isLoggedIn() |
| 22 | ★ v3.1:Option::get() 第二参数永远是 ''——传 null 会在严格类型下崩 |
19.2 常见错误推理链
❌ 错误链 A:路由不生效 → "可能 PluginRegistry 不存在" → 改用 Hook → 引入新问题
✅ 正确链:读 dispatcher.php → 发现路由在核心匹配 → 检查 PluginRegistry::matchFrontendRoute → 检查自己的注册代码 → 修复
❌ 错误链 B:CSRF 报错 → "可能 Csrf 类没加载" → 加 class_exists 判断 → 还是错
✅ 正确链:CSRF 报错 → 看报错是"缺少令牌"还是"不匹配" → "缺少"就检查表单/AJAX 是否带 token → "不匹配"就检查 session 是否丢了
❌ 错误链 C:权限拒绝 → "可能 Auth 类不存在" → 加 fallback → 引入安全漏洞
✅ 正确链:权限拒绝 → 看当前角色 → 看能力表 → 能力名对不对 → 角色对应用户 → 修复
第 20 章:给 AI 的指令模板
20.1 从零开发新插件
请按 LiteLog 插件开发规范 v3.1 开发一个 XXX 插件:
【附件:LiteLog 插件开发规范 v3.1 完整版.md】
需求:
- 功能:……
- 前台入口:URL ……,action=……
- 后台入口(可选):菜单名「……」,action=……,设置页放 `{插件名}_setting.php`
- 数据表:……
严格遵守本文件所有条款,特别是:
- 第 1 章「500 错误免疫」的 8 条口诀
- 第 2 章「四条硬性安全规则」,特别是 2.4 CSRF 和 2.5 Auth
- 第 4 章「自动加载真相」(主文件必须 require 子文件)
- 第 8.5 节「输出缓冲」(不要碰 ob_start)
- 第 10 章「视图必须 include 主题 header/footer」+「表单必须加 CSRF」
- 第 11 章「CSS/JS 走 layout_* 注入」
- 第 14.4 节「AJAX 九条铁律」
- 第 18 章交付前自检清单
不要修改任何核心文件。完成后给出完整文件列表和每个文件的完整代码。
20.2 排查 500 错误
我的插件启用后后台 500。请根据规范第 1 章排查。
【附件:LiteLog 插件开发规范 v3.1 完整版.md】
报错信息:……
20.3 排查 CSRF 错误
我的插件出现 CSRF 校验失败:……
【附件:LiteLog 插件开发规范 v3.1 完整版.md】
当前代码:(贴表单 / AJAX / 端点)
日志:(content/backup/plugin-error.log)
请按第 2.4 节排查。
20.4 排错通用模板
我的插件出现以下症状:……
【附件:LiteLog 插件开发规范 v3.1 完整版.md】
当前代码:(贴主文件 + 视图 + 路由 + 设置页)
内核相关信息:(贴 dispatcher.php / 主题 header.php)
日志:(content/backup/plugin-error.log)
请按以下顺序排查:
1. 先读内核源码确认机制(不要猜)
2. 再检查代码是否符合机制
3. 给出最小修改方案
4. 给出验证方法
20.5 只写一个 AJAX 端点
请写一个 LiteLog 的 AJAX 端点,实现「……」:
【附件:LiteLog 插件开发规范 v3.1 完整版.md】
要求:走 public_action_xxx 钩子,返回 JSON。
参考第 14.4 节「AJAX 端点九条铁律」,尤其是登录校验 + CSRF 校验。
收尾:一句话总结
「define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require。」
v3.0 的五条核心原则:
① 视图 include 主题 header/footer **② 资源走layout_*Hook 注入** ③ 不要用ob_start('callback')拦截页面 ④ 必须用输出缓冲时,用Output::capture()+ 长度守卫 ⑤ 机制不明,先读源码
v3.1 新增三条:
⑥ 所有 POST 表单加Csrf::field(),AJAX 带X-CSRF-Token⑦ 后台操作前调Auth::requireCap(),不要只用isLoggedIn()⑧ 插件设置页放{插件名}_setting.php,走?action=plugin_setting&name=xxx访问
掌握这 8 + 5 + 3 条 + 三条硬性规则 + 八条红线,99% 的插件能一次跑通。
本文件基于 LiteLog 内核 v1.2(2026-10-12)+ default 主题 v1.3.0 逐行核对整理。
v3.1 相对 v3.0 的主要新增:
- CSRF 全局防护机制(第 2.4 节)
- 后台权限系统 Auth(第 2.5 节)
- 前台评论表单 CSRF(第 10.2 节)
- 插件设置页统一入口(第 7.2 节)
- AJAX 端点九条铁律(第 14.4 节)
- 会话安全强化(第 13.2 节)
- 第 19 章从 18 条扩到 22 条,加入 CSRF/Auth 教训
- 第 17 章骨架加入 CSRF 示例 + 设置页模板
若内核继续更新,请同步更新第 2 章「安全规则」、第 7 章「注册表」、第 8 章「钩子系统」、第 9 章「数据库」、第 14 章「API 参考」五章。