主站
论坛首页/ LiteLog /帖子

LiteLog 插件开发规范 v6.0

作者:zhangwei 管理员 · 1 小时前 · 4 浏览 · 0 回复
zhangwei
楼主
管理员
1 楼
版本:6.0(2026-10-18) 定位:AI 只读此文件即可开发插件。与 LiteLog 内核 v2.1 源码逐行核对,性能条款全部量化。 结构:总则 → 目录 → 主文件 → 架构 → 路由 → 注册表 → 钩子 → 数据库 → 视图 → 资源 → CSRF → 小工具 → 安全 → 调试 → 骨架 → 自检 → 铁律 → 指令 → 附录。

核对基准:init.php / include/lib/{dispatcher,hook,plugin_registry,function.common,mysql,option,csrf,auth,url,view,output,widget}.php / admin/index.php / admin/views/layout.php / admin/upload.php。


第 1 章 总则:必读的硬性规则

1.1 三条硬性安全规则(一票否决)

规则 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>';      // ❌

1.2 九条最高级别红线

#红线
1严禁 unserialize() / eval() / assert() / call_user_func($_GET[...])
2严禁滥用绝对路径(只用 LITELOG_ROOT / CONTENT_PATH / ADMIN_PATH / INCLUDE_PATH / DIR)
3渲染钩子(layout_* / template_* / nav_* / admin_*)内禁 die() / exit() / header(),必须 return
4上传必须白名单 + getimagesize() 校验 + bin2hex(random_bytes(16)) 随机名
5数量扣减必须原子:UPDATE ... WHERE stock>0 + rowCount()
6IN (...) 必须 array_map('intval') + 占位符
7PDO 属性不得覆盖(内核已设 ERRMODE_EXCEPTION / EMULATE_PREPARES=false)
8AJAX 写操作必须登录校验,且先登录后 CSRF
9所有表单 / AJAX URL 必须绝对路径:BLOG_URL . '?...',禁 ?action=xxx

1.3 500 免疫的十条口诀

define 判 defined,function 判 exists,
Option 用空串,callback 整段包,
DDL 去 COMMENT,视图先守卫,
Hook 也包 try,routes 要 require,
PHP 标签别写乱,顶层副作用莫要犯。

十宗罪速查:

#罪状正例
1顶层 define() 无守卫if(!defined('X'))define('X','1');
2顶层 function 无守卫if(!function_exists('f')){function f(){}}
3Option::get() 第二参数传 nullOption::get('x', '')
4生命周期 DDL 带 COMMENT / COLLATE / 无 try/catch见 §3.3
5视图调用插件函数无 function_exists 守卫见 §9.1
6Hook::addAction() 无 try/catch见 §7.3
7PluginRegistry 调用无 class_exists 守卫if(class_exists('PluginRegistry'))
8连续两个 <?php见 §9.5
9主文件顶层副作用(DB / IO / HTTP / 重循环 / Option::set)见 §3.4
10钩子里 $this->e()($this 不可用)用 h(),见 §7.4

1.4 ⭐ 五条性能红线(v6.0 新增,一票否决)

性能是功能的一部分。违反任一红线,插件上线后会拖慢全站。量化预算:单插件单请求开销 < 3ms;SQL 增量 < 10 条;内存增量 < 5MB;前台总开销 < 30ms;后台总开销 < 100ms。

#性能红线违规后果正确做法
P1主文件顶层禁止副作用(查库 / IO / HTTP / 重循环 / Option::set)全站每个请求都付代价移到 admin/register.php / frontend/routes.php / callback_init() / controller
P2Hook 回调必须 O(1) 且 < 5ms,开头必须快速短路每次触发都查库/远程回调第一行 if (条件不符) return;;重活移到 AJAX 或缓存
P3单次请求所有 Hook 的 SQL 总数 ≤ 5 条页面变慢主因用 Option::preload() 合并;用 IN 批查;用 GROUP BY
P4禁止 N+1 查询(循环内 fetchOne / fetchAll)N 篇文章 = N 次查询一次性 IN 批查或 GROUP BY
P5**禁止 SELECT 拉全表、禁止 ORDER BY RAND()*万行以上拖垮 DB明确列名 + LIMIT;随机用采样法

第 2 章 目录结构与自动加载真相

2.1 标准目录

content/plugins/{插件名}/
├── {插件名}.php                    ← 【必须】主文件(所有 context 加载)
├── {插件名}_callback.php           ← 【可选】生命周期(启用/停用/卸载时主动 require)
├── {插件名}_setting.php            ← 【可选】设置页(通过 ?action=plugin_setting 访问)
├── admin/
│   ├── register.php                ← 【可选】后台注册(仅 admin context 自动加载)
│   ├── pages/                      ← 后台视图
│   └── assets/                     ← 后台资源
├── frontend/
│   └── routes.php                  ← 【可选】前台路由(仅 frontend context 自动加载)
├── views/                          ← 前台视图
├── assets/                         ← 静态资源
└── data/                           ← 私有数据(必须带 .htaccess)

2.2 自动加载真相(与内核 loadActivePlugins() 逐行核对)

文件自动加载?加载条件(源码)
{插件名}.php✅ 是所有 context:require_once $base . $p . '.php'
{插件名}_callback.php❌ 否内核在启用/停用/卸载时主动 require
{插件名}_setting.php❌ 否通过 ?action=plugin_setting&name=xxx 访问
admin/register.php✅ 是$context === 'admin' \|\| $context === 'all'
frontend/routes.php✅ 是$context === 'frontend' \|\| $context === 'all'
views/*.php❌ 否控制器 / 路由回调主动 include
assets/*❌ 否Hook 或视图引用

context 判定(detectRequestContext()):PHP_SAPI === 'cli' → 'all';SCRIPT_NAME 含 /admin/ → 'admin';否则 'frontend'。

推荐做法:admin/register.php 和 frontend/routes.php 放约定路径,让内核自动加载;主文件不要重复 require。若必须放非约定路径,主文件用 require_once 显式加载。

2.3 插件名约束

插件名同时是目录名和伪静态 URL 前缀(/插件名/xxx),禁用:

类型禁用清单
真实目录admin / content / include / backup / cache / uploads / plugins / templates / assets / data
核心伪静态前缀post / sort / tag / author / record / page

2.4 data/ 目录保护

content/plugins/{插件名}/data/ 必须放 .htaccess:

<IfModule mod_authz_core.c>
    Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
    Order deny,allow
    Deny from all
</IfModule>

第 3 章 主文件与生命周期

3.1 主文件标准骨架

<?php
/**
 * Plugin Name: 我的插件
 * Description: 一句话描述
 * Version: 1.0.0
 * Author: 你的名字
 * Author URI: https://example.com
 */

if (!defined('LITELOG_ROOT')) exit('error!');

/* ① 常量守卫 */
if (!defined('MP_VER')) define('MP_VER', '1.0.0');

/* ② 工具函数(必须 function_exists 守卫) */
if (!function_exists('MP_isEnabled')) {
    function MP_isEnabled() {
        if (!class_exists('Option')) return true;
        try { return Option::get('my_plugin_enabled', '1') === '1'; }
        catch (Throwable $e) { return true; }
    }
}

/* ③ 注册 Hook(class_exists + try/catch 包裹) */
if (class_exists('Hook')) {
    try {
        Hook::addAction('layout_head_end', 'MP_injectCss');
        Hook::addAction('layout_footer_scripts', 'MP_injectJs');
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin hook register: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

/* ④ 子文件加载(可选:放约定路径时内核会自动加载,见 §2.2) */

3.2 头注释字段

Plugin Name / Name / Description / Version / Author / Author URI / Author URL

3.3 生命周期 callback

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

function callback_init() {
    try {
        global $DB;
        $DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "my_plugin_items` (
            `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            `name` VARCHAR(100) NOT NULL DEFAULT '',
            `created_at` INT UNSIGNED NOT NULL DEFAULT 0
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        if (class_exists('Option')) {
            foreach (['enabled' => '1', 'title' => '我的插件'] as $k => $v) {
                $optKey = 'my_plugin_' . $k;
                if (Option::get($optKey, '') === '') Option::set($optKey, $v);
            }
        }
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin callback_init: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

function callback_up() { callback_init(); }
function callback_rm() { /* 保留数据 */ }

function callback_uninstall() {
    global $DB;
    try { $DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "my_plugin_items`"); } catch (Throwable $e) {}
    try { $DB->delete('option', "option_name LIKE 'my_plugin_%'"); } catch (Throwable $e) {}
    try {
        $files = glob(CONTENT_PATH . 'cache/my_plugin_*');
        if ($files) foreach ($files as $f) @unlink($f);
    } catch (Throwable $e) {}
}

铁律:callback_init() 整段 try/catch;DDL 无 COMMENT / COLLATE;callback_rm() 不删数据;callback_uninstall() 顺序 = 删表 → 清 option → 清缓存文件;生命周期文件不输出任何内容。

3.4 主文件顶层禁止副作用(P1 红线)

主文件被所有 context 无条件加载,顶层副作用会强加给全站每个请求:

// ❌ 全部禁止
$count = $DB->fetchColumn("SELECT COUNT(*) FROM ...");         // 查库
$config = json_decode(file_get_contents(__DIR__.'/c.json'));  // 读文件
$resp = file_get_contents('https://api.example.com/xxx');      // HTTP
for ($i = 0; $i < 10000; $i++) { ... }                        // 重循环
Option::set('site_title', 'xxx');                              // 修改全局配置

// ✅ 允许:常量守卫、函数定义、Hook::addAction 注册、require 子文件

判定:顶层只做"声明性"的事。任何"执行性"的事都移到 admin/register.php / frontend/routes.php / callback_init() / controller 里。


第 4 章 内核架构与请求生命周期

4.1 前台流程(与 init.php / dispatcher.php 核对)

请求 → index.php
  ↓
init.php
  ├─ 定义常量(LITELOG_ROOT / CONTENT_PATH / ADMIN_PATH / INCLUDE_PATH)
  ├─ require config.php → 定义 DB_PREFIX
  ├─ 启动 Session(HttpOnly + SameSite=Lax + use_strict_mode)
  ├─ require 所有 lib/*.php
  ├─ 建立 PDO($DB 全局)
  ├─ Option::init() 加载 autoload='y' 的配置
  ├─ require 所有 model / controller
  ├─ 定义 BLOG_URL
  ├─ ★ 维护模式检查(site_maintenance='y' 时拦截)
  ├─ ★ 移动端 router(isMobileDevice() == true 时加载,可能 exit)
  └─ loadActivePlugins()  ← 你的插件在这里加载
  ↓
Dispatcher::dispatch()
  ├─ Hook::doAction('dispatch_start')
  ├─ ★ matchPluginUri():识别 /插件名/xxx → __plugin_path(受 plugin_rewrite 开关控制)
  ├─ dispatch_plugin_route(若有 __plugin_path)
  ├─ 标准 Rewrite URL(post/sort/tag/author/record/page)
  ├─ PluginRegistry::matchFrontendRoute()
  ├─ public_action_{action}
  ├─ dispatch_matched → 控制器执行 → View::display()(触发 view_before / view_after)
  └─ dispatch_end(shutdown 兜底)

4.2 后台流程(与 admin/index.php 核对)

admin/index.php
  ├─ 定义 LITELOG_ROOT,require init.php
  ├─ require admin/helpers.php
  ├─ 处理 action=login / logout
  ├─ checkLogin() 强制登录(未登录 → redirect(BLOG_URL . 'admin/?action=login'))
  ├─ ★ CSRF 校验:POST 且非插件页面/设置页 → Csrf::verify()
  │   $isPluginPage    = PluginRegistry::hasAdminPage($action)
  │   $isPluginSetting = ($action === 'plugin_setting')
  ├─ Hook::doAction('admin_request', $action)
  ├─ PluginRegistry::renderAdminPage($action)(若注册过)
  ├─ PluginRegistry::renderPluginSetting($name)(action=plugin_setting)
  └─ 内置路由 switch

关键:插件后台页面和插件设置页跳过核心全局 CSRF 校验,必须自行校验(见 §11.2)。

4.3 移动端 router 抢跑

init.php 先加载 mobile router,后加载插件。mobile/router.php 里的 exit 分支会让插件钩子全部失效。

应对:

  • 推荐:插件路由用 PluginRegistry::addFrontendRoute() 或 dispatch_plugin_route 钩子(都在 dispatch() 内部,不受 mobile router 影响)。
  • 备选:让 mobile/router.php 未匹配时 return 而非 exit。

4.4 会话安全(init.php 已配置)

use_strict_mode=1 / HttpOnly=true / SameSite=Lax / Secure(HTTPS 自动开)/ Session 名 LITELOG_SID / session_regenerate_id(true) 登录后调用。


第 5 章 路由系统

5.1 三种路由方式

方式触发适用
dispatch_plugin_route 钩子核心 matchPluginUri() 识别 /插件名/xxx推荐
PluginRegistry::addFrontendRoute($regex, $cb)核心 matchFrontendRoute() 正则匹配需自定义 URL
public_action_xxx 钩子?action=xxx传统兼容

5.2 核心 matchPluginUri() 细节(dispatcher.php 已内置)

  • 排除核心前缀 ['post/', 'sort/', 'tag/', 'author/', 'record/', 'page/']
  • 检查 is_file(CONTENT_PATH . 'plugins/' . $name . '/' . $name . '.php')
  • 匹配成功设置 $_GET['__plugin_path'] 和 $_GET['__plugin_rest']
  • 受 Option::get('plugin_rewrite', 'y') === 'y' 控制

plugin_rewrite 开关:

值前台 URL场景
'y'(默认)/插件名/xxx已配 Nginx 重写
'n'?action=xxx未配重写,任意服务器可用

5.3 dispatch_plugin_route 分发模板(★ 必须快速短路)

Hook::addAction('dispatch_plugin_route', function ($pluginName, $rest) {
    /* ★ 快速短路:不是我的路由立刻 return(P2 性能红线) */
    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;   // 找不到匹配,让核心继续
});

铁律:第一行必须 if ($pluginName !== 'my_plugin') return;——否则 N 个插件的回调都会执行一遍,浪费全站每个请求的时间。

5.4 URL 生成函数(推荐)

if (!function_exists('MP_url')) {
    function MP_url($sub = '', array $params = []) {
        $useRewrite = class_exists('Url') && Url::isPluginRewriteOn();
        if ($useRewrite) {
            $base = rtrim(BLOG_URL, '/') . '/my_plugin';
            if ($sub !== '') $base .= '/' . ltrim($sub, '/');
            if (!empty($params)) $base .= '?' . http_build_query($params);
            return $base;
        }
        return BLOG_URL . '?' . http_build_query(
            array_merge(['action' => 'my_plugin'], $params, $sub ? ['sub' => $sub] : [])
        );
    }
}

第 6 章 PluginRegistry 注册表(与 plugin_registry.php 核对)

6.1 API

方法用途
addAdminPage($action, $config)注册后台页面
hasAdminPage($action)是否存在
getAdminPage($action)获取配置
addAdminMenu($menu)注册侧边栏菜单
getAdminMenus()获取所有菜单(按 priority 排序)
addFrontendRoute($regex, $cb)注册前台路由(分桶索引 O(1))
matchFrontendRoute($uri)匹配前台路由(内核调用)
addAdminAsset($action, $type, $url)注册后台资源
enqueueAsset($type, $url)手动暂存资源
getPendingAssets($type)读取暂存资源(layout 渲染时调用)
renderAdminPage($action)渲染后台页面(内核调用)
renderPluginSetting($name)渲染设置页(内核调用,自动鉴权 + 包 layout)

6.2 注册后台页面

// admin/register.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;

try {
    PluginRegistry::addAdminMenu([
        'id' => 'friend_links', 'action' => 'friend_links',
        'title' => '友情链接', 'icon' => 'external', 'priority' => 30,
    ]);
    PluginRegistry::addAdminPage('friend_links', [
        'title' => '友情链接管理',
        'callback' => function () {
            if (class_exists('Auth')) Auth::requireCap('plugin.manage');
            include __DIR__ . '/pages/list.php';
        },
    ]);
} catch (Throwable $e) { /* 写日志 */ }

可用 icon:dashboard / file / comment / folder / settings / external / logout / plus / edit / trash / plugin / palette / check / x / refresh

6.3 插件设置页

访问入口:<?= BLOG_URL ?>admin/?action=plugin_setting&name=xxx

内核做的事(renderPluginSetting()):校验正则 → 校验文件存在 → Auth::requireCap('plugin.manage') → define('IN_PLUGIN_SETTING', true) → ob_start() + include $file → 包上 admin/views/layout.php。

设置页模板:

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!defined('IN_PLUGIN_SETTING')) { http_response_code(404); exit('Not Found'); }
if (!function_exists('MP_isEnabled')) { echo '插件未加载'; return; }

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (class_exists('Csrf')) Csrf::verify();
    /* ★ 字段缺失跳过(§13.4) */
    if (array_key_exists('title', $_POST)) {
        Option::set('my_plugin_title', trim($_POST['title']));
    }
    redirect(BLOG_URL . 'admin/?action=plugin_setting&name=my_plugin&saved=1');
}
?>
<h2>我的插件设置</h2>
<?php if (!empty($_GET['saved'])): ?>
  <div class="card" style="background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46">设置已保存</div>
<?php endif; ?>
<form method="post" class="form">
  <?= Csrf::field() ?>
  <div class="field">
    <label>插件标题</label>
    <input type="text" name="title" value="<?= h(Option::get('my_plugin_title', '')) ?>">
  </div>
  <button class="btn" type="submit">保存</button>
</form>

注意:设置页不要自己 include layout(内核已包);设置页里 $this 不可用。

6.4 前台路由注册

// frontend/routes.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;

try {
    PluginRegistry::addFrontendRoute('icon/?', function ($matches) {
        if (!function_exists('MP_isEnabled') || !MP_isEnabled()) {
            header('HTTP/1.0 404 Not Found');
            if (class_exists('View')) View::display('404');
            return;
        }
        $GLOBALS['__mp_active'] = true;
        include __DIR__ . '/../views/generator.php';
    });
} catch (Throwable $e) { /* 写日志 */ }

分桶索引:addFrontendRoute 按正则首段提取桶(^forum/ → 'forum'),matchFrontendRoute() 优先精确桶后通配桶,O(1) 匹配。

6.5 后台资源

PluginRegistry::addAdminAsset('my_plugin', 'css', BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.css');
PluginRegistry::addAdminAsset('my_plugin', 'js',  BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.js');

第 7 章 钩子系统(与 hook.php 逐行核对)

7.1 Hook API

Hook::addAction($hook, $func, $priority = 10);   // 注册(越小越先执行)
Hook::doAction($hook, ...$args);                 // 触发(内部 ksort 排序)
Hook::removeAction($hook, $func = null);         // 移除
Hook::hasAction($hook);                          // 是否挂载
Hook::captureAction($hook, ...$args);            // 捕获输出
Hook::applyFilters($hook, $value, ...$args);     // 过滤器(第一参数是值,返回值替换)

Action vs Filter:

语义方法参数传递
Action(通知型)doAction引用传参改数据:function (&$data, ...)
Filter(过滤器)applyFilters第一参数是值,返回值替换;返回 null 保留原值

7.2 钩子全表(★ 按"触发方"分类)

核心触发 = include/ 或 admin/ 代码里有 Hook::doAction('name')。 主题触发 = 只有主题 content/templates/{主题}/*.php 里触发(核心不触发),老主题可能未实现。

A. 生命周期(核心触发)

钩子触发点参数
plugin_activateadmin/index.php 启用插件后$name
plugin_deactivateadmin/index.php 停用插件后$name
plugin_uninstalladmin/index.php 卸载插件后$name

B. 布局锚点(★ 主题触发,核心不触发)

钩子推荐用途
layout_head_start<head> 最前
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> 之前

⚠️ 重要:layout_* 钩子由主题触发,核心 default 主题(v1.3.1)已实现。老主题或自定义主题可能未实现。插件应同时注册 template_* 兼容钩子(见 C 组)。

C. 传统布局钩子(★ 主题触发,兼容老主题)

template_head / body_start / header_before / header_after / before_content / after_content / footer_before / footer_after / template_footer / body_end

D. 视图(核心触发)

钩子触发点参数
view_beforeView::display() 前$name, $data
view_afterView::display() 后$name, $data

E. 分发路由(核心触发)

钩子触发点参数
dispatch_startDispatcher::dispatch() 开头无
dispatch_plugin_route__plugin_path 分发$pluginName, $rest
dispatch_matched路由匹配后&$matched
dispatch_end分发结束(shutdown 兜底)无
public_action_{action}?action=xxx 分发无

F. 内容列表(核心触发)

钩子触发点参数
log_list_query列表查询前&$cond, $page, $perPage
log_list_result列表查询后&$logs, $page, $total
sort_display_query分类页查询前$sort, &$cond, $page, $perPage
sort_display分类页渲染前$sort, $logs
tag_display_query标签页查询前$tag, &$cond, $page, $perPage
tag_display标签页渲染前$tag, $logs
record_display_query归档页查询前$year, $month, &$cond, $page, $perPage
record_display归档页渲染前$year, $month, $logs
search_before搜索前$keyword
search_result搜索结果后$keyword, $logs
index_head首页头部无

G. 内容详情(核心触发)

钩子触发点参数
log_display_before详情渲染前$log
log_display_after详情渲染后$log
log_content正文输出$log
log_save_before文章保存前(Filter)$data, $gid
log_save_after文章保存后$gid, $data
admin_log_save_before后台文章保存前(Filter)$data, $gid
admin_log_save_after后台文章保存后$gid, $data
admin_log_edit_sidebar后台文章编辑侧边栏$log
comment_save_before评论提交前(Filter)$data
comment_save_after评论插入后$cid, $data
comment_post评论提交后(兜底)$data

H. 后台(核心触发)

钩子触发点参数
admin_requestadmin/index.php CSRF 校验后$action
admin_menuadmin/views/layout.php 侧边栏$activeKey
admin_headlayout.php <head>无
admin_footerlayout.php </body> 前无
admin_dashboard_widgets概览页无

I. 扩展点(核心触发)

钩子用途
register_widgets注册小工具(WidgetRegistry::init() 里触发)
register_homepage_modes注册首页模式

J. 系统事件(核心触发)

钩子触发点参数
user_login登录成功后$user
user_logout登出时$uid
theme_activate切换主题后$name, $old
option_save保存 option 后$key, $value

K. 主题侧钩子(★ 主题触发,核心不触发)

comment_section_before / comment_section_after(评论区前后,$log)—— 需主题实现。

7.3 钩子注册规范

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::addAction 重复调用会追加,不会覆盖——不要在每次请求中重复注册。插件自己触发供其它插件监听的钩子必须加插件名前缀:Hook::doAction('my_plugin_after_save', $id, $data);。

7.4 钩子回调作用域与 $this(★ 必读)

Hook::doAction 内部用 call_user_func_array($func, $args) 调用,回调在静态方法上下文中执行:

  • 回调是普通函数名或匿名函数:$this 不可用
  • 回调是对象方法([$obj, 'method']):$this 指向 $obj(不推荐)

症状:钩子里写 $this->e() / $this->getData() / $this->icon() → PHP 8 抛 Using $this when not in object context → 被 Hook::doAction 的 try/catch 吞成"HTML 渲染到一半断了、HTTP 仍 200"。

正确写法:

视图里钩子里
$this->e($s)h($s) 或 htmlspecialchars($s, ENT_QUOTES, 'UTF-8')
$this->url('/path')BLOG_URL . 'path' 或 MP_url(...)
$this->icon($name)直接 adminIcon($name)(后台)或 Font Awesome 字符实体
$this->getData('key')$GLOBALS['key'] ?? null 或由钩子参数传入

排查口诀:插件区块半截输出 → 先看 error.log 有没有 Using $this。

7.5 ⭐ 钩子回调必须快速短路(P2 性能红线)

每个 Hook 回调都会在所有请求中被触发(除非条件不符)。回调第一行必须是快速短路:

// ❌ 反例:每次请求都执行完整逻辑
Hook::addAction('view_before', function ($name, $data) {
    global $DB;
    $rows = $DB->fetchAll("SELECT * FROM ...");   // 每个页面都查!
    // ...
});

// ✅ 正例:第一行快速短路
Hook::addAction('view_before', function ($name, $data) {
    if ($name !== 'echo_log') return;             // 不是我关心的模板,立刻返回
    // ...
});

必须短路的钩子:

钩子短路条件
dispatch_plugin_routeif ($pluginName !== 'my_plugin') return;
view_before / view_afterif ($name !== 'expected_template') return;
layout_head_endif (empty($GLOBALS['__mp_active'])) return;
public_action_xxx核心已按 action 名分发,无需短路
log_list_result判当前页面类型

7.6 双触发 / 重复触发陷阱

钩子触发次数注意
dispatch_plugin_route1 次正常
public_action_xxx1 次正常
view_before / view_after每次 View::display() 各 1 次页内多次 View::display() 会多次触发
admin_request1 次/请求正常

Hook::addAction 重复调用会追加——注意不要在每次请求中重复注册(除非用 static 守卫)。


第 8 章 数据库与模型(与 mysql.php 核对)

8.1 Database 类精确签名

方法返回值陷阱
query($sql, $params = [])PDOStatement可 ->rowCount()
fetchAll($sql, $params = [])array(空时 [])—
fetchOne($sql, $params = [])array \| false失败返回 false,不是 null
fetchColumn($sql, $params = [])标量 \| false空结果 false,不是 0
insert($table, $data)string(lastInsertId)必须 (int) 转换
update($table, $data, $where, $whereParams = [])true不是行数!要行数用 query()->rowCount()
delete($table, $where, $params = [])true同上

8.2 使用示例

global $DB;

// 查一条(判 false)
$row = $DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "user` WHERE uid=?", [(int)$uid]);
if ($row === false) { /* 不存在 */ }

// 查多条
$rows = $DB->fetchAll("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `sortid`=? LIMIT 10", [(int)$sid]);

// 标量
$count = (int)$DB->fetchColumn("SELECT COUNT(*) FROM `" . DB_PREFIX . "user` WHERE `status`=1");

// 插入((int) 转换)
$newId = (int)$DB->insert('my_items', ['name' => 'test', 'created_at' => time()]);

// 更新
$DB->update('my_items', ['name' => 'new'], 'id = ?', [(int)$id]);

// 删除
$DB->delete('my_items', 'id = ?', [(int)$id]);

// 原子扣减(判 rowCount)
$stmt = $DB->query("UPDATE `" . DB_PREFIX . "user_profile` SET `credit`=`credit`-1 WHERE `uid`=? AND `credit`>0", [(int)$uid]);
if ($stmt->rowCount() === 0) { return ['ok' => false, 'msg' => '余额不足']; }

// IN 查询(强转 + 占位符)
$ids = array_map('intval', $ids);
if ($ids) {
    $in = implode(',', array_fill(0, count($ids), '?'));
    $rows = $DB->fetchAll("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid` IN ($in)", $ids);
}

8.3 事务($DB->pdo() 返回原生 PDO)

$pdo = $DB->pdo();
$pdo->beginTransaction();
try {
    // ... 业务 ...
    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) $pdo->rollBack();   // ★ 必须判 inTransaction
    throw $e;
}

注意:begin() / commit() 不存在,用 beginTransaction() / commit()。

8.4 核心表清单

表关键字段
{DB_PREFIX}useruid(PK) / username / nickname / email / password / role
{DB_PREFIX}bloggid(PK) / title / content / content_md / alias / sortid / author / date / views / comnum / hide('y'/'n') / type('blog'/'page') / top(TINYINT) / cover / excerpt
{DB_PREFIX}commentcid(PK) / gid / pid / poster / email / url / comment / ip / agent / date / hide('y'待审/'n'通过)
{DB_PREFIX}sortsid(PK) / sortname / alias / pid / description
{DB_PREFIX}tag / {DB_PREFIX}tag_blog标签关联
{DB_PREFIX}optionid(PK) / option_name(UNIQUE) / option_value(TEXT) / autoload

Option::get() 返回永远是 string,判断用 === '1' / === 'y'。

8.5 核心模型速查

模型主要方法
LogModelquery / getOne / getByAlias / latest / prev / next / add / update / delete / addView / count / search
SortModelall / getOne / getByAlias / add / update / delete / countInSort
TagModelall / getByName / getByGid / getGidsByTag / attach / detach
CommentModelgetOne / byLog / byLogPaginated / buildTree / latest / add / delete / deleteMany / countByLog / refreshComnum / hasRecentDuplicate
UserModelgetOne / getByUsername / login / update
LinkModelall

8.6 Option 配置存储(与 option.php 核对)

Option::set('my_plugin_enabled', '1');
$v = Option::get('my_plugin_enabled', '');   // ★ 第二参数永远 ''

Option::set('my_plugin_config', json_encode(['a' => 1], JSON_UNESCAPED_UNICODE));
$cfg = json_decode(Option::get('my_plugin_config', '{}'), true);

// 批量预取(≥ 3 个 key 时用,把 N 次查询压成 1 次)
Option::preload(['my_plugin_a', 'my_plugin_b', 'my_plugin_c']);
$a = Option::get('my_plugin_a', '');

Option::set() 第三参数 $autoload(v2.1 新增):

  • null(默认):关键 key → 'y',其余 → 'n'
  • 'y':强制 autoload(每次请求都读入内存)
  • 'n':强制不 autoload(按需读)

Option::init() 行为:每次请求 SELECT ... WHERE autoload='y',加载到内存。criticalKeys(current_template / url_rewrite / plugin_rewrite / site_title / site_description / site_key / active_plugins)缺失时会兜底查询并强制 UPDATE autoload='y'——这些 key 必须已在 autoload='y',否则每次请求都触发 UPDATE 写库。


第 9 章 视图与模板

9.1 前台视图必须 include 主题 header/footer

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

/* 前置守卫 */
if (!function_exists('MP_isEnabled')) {
    echo '<div class="error">插件未正确加载</div>';
    return;
}

/* 三变量兜底(见 §9.2) */
$page_title  = '我的插件 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

include View::resolve('header');
?>
<div class="my-container">...</div>
<?php include View::resolve('footer'); ?>

View::resolve() 行为(view.php):移动端优先查找 templates/{主题}/mobile/header.php,找不到回退到 templates/{主题}/header.php,再找不到回退到 templates/default/header.php。

9.2 三变量兜底 + JS 覆盖 document.title

问题:主题 header 变量名不可预测,Cookie litelog_view=mobile 可能让 PC 浏览器走移动端模板,导致标题显示站点名。

铁律:视图顶部同时设置三变量 + JS 覆盖:

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

$page_title  = '我的插件 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_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');
?>

为什么绝对有效:三变量覆盖所有主题变量名;JS 覆盖硬编码站点名;JS 绕过服务端缓存/CDN。

9.3 评论表单必须加 CSRF

<form method="post" action="<?= BLOG_URL ?>?action=comment_post">
  <?= Csrf::field() ?>
  <input type="hidden" name="gid" value="<?= (int)$log['gid'] ?>">
  <input type="hidden" name="pid" value="0">
  <textarea name="comment" required></textarea>
  <button type="submit">提交</button>
</form>

9.4 后台视图

后台视图由 PluginRegistry::addAdminPage 的 callback 调用,内核已包好 admin/views/layout.php,不要 include 主题 header/footer。

可用核心 CSS 类:card / card-title / btn / btn-outline / btn-danger / btn-sm / table-wrap / table / form / field / tag / empty / error / pagination

9.5 PHP 语法安全写法

危险写法(连续两个 <?php):

<?php if (!defined('LITELOG_ROOT')) exit('error!'); ?>
<?php
/* 大量代码 */

只要用户在编辑器粘贴时第二个 <?php 丢失,或被转义成 &lt;?php,就会 syntax error, unexpected '<' 导致白屏。

安全写法:合并为一个大 PHP 块,只在输出 HTML 时才闭合 ?>。

验证:php -l content/plugins/your_plugin/views/detail.php(期望 No syntax errors detected)。

9.6 输出转义

<h1><?= h($log['title']) ?></h1>
<a href="<?= h($url) ?>"><?= h($name) ?></a>

第 10 章 资源加载

10.1 前台资源用 layout_* 注入

if (class_exists('Hook')) {
    try {
        Hook::addAction('layout_head_end', function () {
            if (empty($GLOBALS['__mp_active'])) return;
            $ver = (int)(@filemtime(__DIR__ . '/assets/style.css') ?: 1);
            echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/assets/style.css?v=' . $ver . '">' . "\n";
        });
        Hook::addAction('layout_footer_scripts', function () {
            if (empty($GLOBALS['__mp_active'])) return;
            $ver = (int)(@filemtime(__DIR__ . '/assets/script.js') ?: 1);
            echo '<script src="' . BLOG_URL . 'content/plugins/my_plugin/assets/script.js?v=' . $ver . '"></script>' . "\n";
        });
    } catch (Throwable $e) { /* 写日志 */ }
}

视图里标记:$GLOBALS['__mp_active'] = true;

兼容老主题:两个 Hook 都注册(layout_head_end + template_head),用 static 去重。

10.2 后台资源

PluginRegistry::addAdminAsset('my_plugin', 'css', BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.css');
PluginRegistry::addAdminAsset('my_plugin', 'js',  BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.js');

10.3 数据目录保护

content/plugins/{插件名}/data/ 必须放 .htaccess(见 §2.4)。

10.4 版本号(★ 性能相关)

资源 URL 必须带 ?v=filemtime 或 ?v=版本号,避免浏览器缓存旧资源:

$ver = (int)(@filemtime(__DIR__ . '/assets/style.css') ?: 1);
echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/assets/style.css?v=' . $ver . '">';

filemtime() 每次请求都调用会触发一次 stat 系统调用,虽然开销小(< 0.01ms),但如果插件很多(20+)会累积。建议:静态 ?v=版本号(改资源时手动改),或用插件自己的 MP_VER 常量。


第 11 章 CSRF 与权限

11.1 CSRF API

$token = Csrf::token();          // 获取当前会话 token
echo Csrf::field();              // 输出隐藏字段
Csrf::verify();                  // 校验(失败直接 403 并输出)
Csrf::verifyJson($payload);      // 从 JSON body 校验

11.2 表单必须加 CSRF

任何 POST 表单必须:

<form method="post" action="...">
  <?php if (class_exists('Csrf')) echo Csrf::field(); ?>
</form>

核心行为(admin/index.php 核对):$isPluginPage = PluginRegistry::hasAdminPage($action);$isPluginSetting = ($action === 'plugin_setting');if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$isPluginPage && !$isPluginSetting) { Csrf::verify(); }。插件后台页和设置页必须自行校验 CSRF。

11.3 AJAX 必须带 X-CSRF-Token

后台 layout.php 已全局包装 window.fetch,同源请求自动加 header。手动:

fetch(url, {
  method: 'POST',
  headers: {
    'X-CSRF-Token': window.LITELOG_CSRF || '',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(payload)
})

11.4 Auth 权限

Auth::check();                    // 是否登录
Auth::id();                       // 当前用户 uid
Auth::role();                     // admin / editor / author / guest
Auth::can($cap);                  // 是否有某能力
Auth::requireCap($cap);           // 强制校验,失败终止
Auth::canEditLog($log);           // 文章所有者检查
Auth::regenerate();               // 会话再生成

角色能力表:admin → ;editor → log. / comment. / sort. / tag.* / widget.manage / theme.manage / plugin.manage;author → log.own / comment.reply。

11.5 ⭐ 登录态失效:先登录后 CSRF

// ❌ 反例:CSRF 先失败
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    Csrf::verify();                       // 会话过期 → 403
    if (!isLoggedIn()) redirect(...);     // 永远走不到
}

// ✅ 正例:先判断登录
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!isLoggedIn()) redirect(BLOG_URL . 'admin/?action=login');
    Csrf::verify();
}

AJAX 端点登录失效时返回 401 + login_url,让前端跳转:

if (empty($_SESSION['uid'])) {
    echo json_encode([
        'ok' => false, 'msg' => '登录已过期,请重新登录',
        'login_url' => BLOG_URL . 'admin/?action=login',
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

注意:当前核心的 login 分支不支持 next 参数(登录成功后固定跳 BLOG_URL . 'admin/')。如需回跳,插件需自己存 $_SESSION 并在登录后读取。


第 12 章 小工具与首页模式

12.1 自定义小工具(与 widget.php 核对)

class Widget_MyBlock extends Widget {
    public $id          = 'my_block';
    public $name        = '我的模块';
    public $description = '展示自定义内容';
    public $source      = 'plugin:my_plugin';   // 分组用
    public $cacheable   = true;                 // ★ v2.1 必须声明

    public function defaults() {
        return ['title' => '默认标题', 'count' => 5];
    }

    public function form($settings) {
        $s = array_merge($this->defaults(), $settings ?: []);
        $this->fieldText('title', $s['title'], '标题');
        $this->fieldNumber('count', $s['count'], '显示数量', 1, 20);
    }

    public function render($settings, $instanceId) {
        $s = array_merge($this->defaults(), $settings ?: []);
        ob_start();
        echo '<ul>...</ul>';
        $this->output($instanceId, $s['title'], ob_get_clean());
    }
}

Hook::addAction('register_widgets', function () {
    WidgetRegistry::register(new Widget_MyBlock());
});

表单字段必须带 data-setting="键名"(内核 widgets.js 依赖此属性)。

缓存策略判定:

场景$cacheable
最新文章 / 分类 / 标签 / 归档true
随机文章 / 随机标签false
日历(依赖 $_GET['cal'])false
用户头像 / 在线人数false 或 cacheTtl = 60
广告位 / 文本块 / 自定义 HTMLtrue
统计数字(文章数、评论数)true(5 分钟延迟可接受)

render() 内禁止 N+1 查询(P4 红线):用一次 GROUP BY 拿所有分类计数,不要循环 countInSort()。

12.2 Widget 缓存机制(性能关键)

  • WidgetArea::render() 有跨请求缓存(默认 CACHE_TTL = 300 秒)
  • $cacheable=true 且 $cacheTtl > 0 的 Widget 会写缓存文件
  • flushCache() 在 log_save_after / log_delete_after / comment_save_after / comment_delete_after / setData() 时触发,**删除所有 wgt_*.php 缓存文件**

⚠️ flushCache() 开销:每次文章/评论变更都 glob('wgt_*.php') + 循环 @unlink()。如果缓存文件很多(100+),每次写操作会多花 10-50ms。建议:Widget 数量控制在合理范围(< 20),不要给每篇文章配不同 Widget 实例。

12.3 自定义首页模式

class HomepageMode_MyMode extends HomepageMode {
    public $id = 'my_mode'; public $name = '我的模式';
    public $theme = 'purple'; public $icon = '<svg>...</svg>'; public $priority = 50;
    public function isAvailable() { return true; }
    public function render($ctx = []) { echo '<div class="my-mode">...</div>'; }
}
Hook::addAction('register_homepage_modes', function () {
    HomepageModeRegistry::register(new HomepageMode_MyMode());
});

第 13 章 安全与陷阱

13.1 相对 URL 陷阱(最痛的教训)

症状:删除按钮点了没反应,页面刷新但目标还在。

<!-- ❌ 反例:相对 URL -->
<form method="post" action="?action=forum_delete&id=5">

<!-- ✅ 正例:绝对 URL -->
<form method="post" action="<?= BLOG_URL ?>?action=forum_delete&id=5">

原因:/forum/thread/5 页面下 ?action=... 解析为 http://host/forum/thread/5?action=...,被 Nginx 重写规则抢跑,public_action_forum_delete 根本没走到。

JS 动态生成表单同样适用:form.action = '<?= BLOG_URL ?>?action=forum_thread_mod';

兜底:显式读 $_POST 和 $_GET,不依赖 $_REQUEST:$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);

13.2 输出缓冲陷阱

#铁律
1不要用 ob_start('callback') 绑定回调做页面拦截
2不要在 ob_start 的回调里再 ob_start(PHP 禁止)
3回调必须判 is_string($html),非字符串返回 ''
4优先用 layout_* 锚点,其次 dispatch_end + 手动捕获
5绝不要在 ob_start 和 ob_get_clean 之间抛异常
6用 Output::capture() 而不是裸 ob_start / ob_get_clean

13.3 后台设置保存:字段缺失跳过

// ❌ 反例:缺失字段被写成 0
foreach (['enabled', 'max_count'] as $k) {
    Option::set('my_plugin_' . $k, (int)($_POST[$k] ?? 0));
}

// ✅ 正例:缺失即跳过
foreach (['enabled', 'max_count'] as $k) {
    if (!array_key_exists($k, $_POST)) continue;
    Option::set('my_plugin_' . $k, (int)$_POST[$k]);
}

注意:多选框组(features[])用户全部取消时 $_POST['features'] 不存在,语义是"清空"——这种场景要保留"缺失 = 清空",不要跳过。

13.4 上传路径与站内资源白名单

admin/upload.php 上传成功后返回站内绝对路径(带前导 /,不带域名),如 /content/uploads/2026-10/xxx.png。

如果插件自己做图片路径校验,必须同时接受:

if (!function_exists('MP_safeImagePath')) {
    function MP_safeImagePath($path) {
        $path = trim((string)$path);
        if ($path === '' || strlen($path) > 500) return '';
        if (preg_match('/[\x00-\x20\x7f\\\\]/', $path)) return '';
        if (strpos($path, '//') === 0) return '';
        if (preg_match('#^[A-Za-z][A-Za-z0-9+.\-]*:#', $path)) {
            return stripos($path, 'https://') === 0 ? $path : '';
        }
        if (strpos($path, '..') !== false) return '';
        if (!preg_match('#^/?[A-Za-z0-9_\-./]+$#', $path)) return '';
        return $path;
    }
}

自建上传时:basename() + 扩展名白名单 + getimagesize() + bin2hex(random_bytes(16)) + ?v=filemtime。强烈建议直接复用 admin/upload.php。

13.5 其他常见坑

坑症状修复
全角标点紧跟 $var消息变成"错误::请重试",无报错用 {$var} 明确边界
Hook::addAction 重复调用钩子回调执行 N 次用 static 守卫或 Hook::removeAction
设置页未定义 IN_PLUGIN_SETTING直接访问报错文件头部检查 defined('IN_PLUGIN_SETTING')
callback_init() 输出内容启用插件时页面白屏生命周期文件不输出任何内容
插件名与真实目录冲突访问 /插件名/ 403 / 404见 §2.3
layout_* 钩子不触发资源没加载主题未实现该钩子,需同时注册 template_*

13.6 ⭐ 性能陷阱(v6.0 新增)

#陷阱后果修复
1主文件顶层查库 / IO全站每请求都付代价见 §3.4
2Hook 回调无快速短路N 个插件 × M 次触发 = N×M 次执行见 §7.5
3循环内 fetchOneN 篇文章 = N 次查询用 IN 批查或 GROUP BY
4SELECT * 拉全表内存暴涨 + 网络慢明确列名 + LIMIT
5ORDER BY RAND()万行以上全表排序随机 ID 采样
6Option::get() ≥ 3 次N 次查询Option::preload()
7未声明 $cacheable 的 Widget每次请求都渲染声明 $cacheable
8曝光统计逐条写库读请求变 N 次写请求渲染期收集,响应后批量
9> 3 秒任务同步执行请求超时队列化(写 jobs 表 + cron 消费)
10flushCache() 删 100+ 文件每次写操作 10-50msWidget 数量控制在合理范围

第 14 章 调试与诊断

14.1 症状 → 第一步

症状第一步
后台 500打开 display_errors,看报错行号
前台白屏F12 Console 看 JS;Network 看 HTML
路由不生效读 dispatcher.php 的 matchPluginUri();检查 plugin_rewrite 开关
标题不对读主题 header.php 和 mobile/header.php
CSS/JS 没加载F12 Network 看状态码;检查 layout_* 钩子是否被主题实现
CSRF 校验失败检查表单是否有 Csrf::field();AJAX 是否带 X-CSRF-Token
权限拒绝检查 Auth::can() / Auth::requireCap() 的能力名
删除/修改无效检查表单 action 是不是相对 URL
语法错误白屏php -l 检查每个文件
钩子区块半截查 plugin-error.log 有没有 Using $this
网站变慢用 §14.5 性能探针定位

14.2 错误日志路径

日志内容
content/backup/plugin-error.log插件主逻辑错误
content/backup/hook-error.log钩子执行错误(Hook::doAction 自动写)
content/backup/route-error.log路由注册错误
content/backup/mobile-router-error.log移动端 router 错误
content/backup/db-error.log数据库连接错误
content/backup/query-error.log数据库查询错误
content/backup/comment-error.log评论错误
content/backup/probe.log探针日志
content/backup/perf.log性能日志

14.3 独立诊断脚本 _diag_plugin.php

<?php
/* 用法:放到网站根目录,浏览器访问 /_diag_plugin.php,用完删除 */

while (ob_get_level() > 0) { @ob_end_clean(); }
if (!defined('LITELOG_ROOT')) define('LITELOG_ROOT', __DIR__ . DIRECTORY_SEPARATOR);
$_GET['force_view'] = 'desktop';
$_COOKIE['litelog_view'] = 'desktop';
@ini_set('display_errors', '1');
@ini_set('display_startup_errors', '1');
@error_reporting(E_ALL);
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>插件诊断</title>
<style>body{font-family:Consolas,Menlo,monospace;font-size:13px;line-height:1.65;padding:22px;background:#0f172a;color:#e2e8f0}
h1{color:#60a5fa}h2{color:#a78bfa;font-size:15px;margin:26px 0 10px;border-bottom:1px solid #334155}
.tag{padding:1px 9px;border-radius:5px;font-size:11.5px;font-weight:700;margin-right:8px}
.tag-ok{background:#064e3b;color:#6ee7b7}.tag-err{background:#7f1d1d;color:#fca5a5}
.tag-warn{background:#78350f;color:#fcd34d}pre{background:#1e293b;padding:11px;border-radius:7px;white-space:pre-wrap}</style>
</head><body>
<h1>🔍 插件诊断报告</h1>
<p><?= date('Y-m-d H:i:s') ?> · PHP <?= PHP_VERSION ?></p>

<?php
function diag_row($tag, $text) { echo '<div><span class="tag tag-' . $tag . '">' . strtoupper($tag) . '</span>' . $text . '</div>'; }

echo '<h2>阶段 1 · 加载核心</h2>';
try { require_once LITELOG_ROOT . 'init.php'; diag_row('ok', 'init.php 加载完成'); }
catch (Throwable $e) { diag_row('err', 'init.php 异常:' . htmlspecialchars($e->getMessage())); echo '</body></html>'; exit; }

echo '<h2>阶段 2 · 核心类与常量</h2>';
foreach (['Option','Hook','View','Csrf','Auth','Database','Dispatcher','PluginRegistry'] as $cls) {
    diag_row(class_exists($cls) ? 'ok' : 'warn', '类 ' . $cls);
}
foreach (['LITELOG_ROOT','BLOG_URL','CONTENT_PATH','DB_PREFIX','TEMPLATE_URL'] as $c) {
    diag_row(defined($c) ? 'ok' : 'err', '常量 ' . $c . (defined($c) ? ' = ' . htmlspecialchars((string)constant($c)) : ' 未定义'));
}

echo '<h2>阶段 3 · 插件状态</h2>';
$pluginName = 'my_plugin';
$active = (string)Option::get('active_plugins', '');
diag_row(strpos(',' . $active . ',', ',' . $pluginName . ',') !== false ? 'ok' : 'err', $pluginName . ' 已启用');
diag_row('info', 'url_rewrite = ' . Option::get('url_rewrite', 'n'));
diag_row('info', 'plugin_rewrite = ' . Option::get('plugin_rewrite', 'y'));
diag_row('info', 'isMobileDevice() = ' . (function_exists('isMobileDevice') && isMobileDevice() ? 'YES' : 'NO'));

echo '<h2>阶段 4 · 视图语法检查</h2>';
$base = CONTENT_PATH . 'plugins/' . $pluginName . '/';
foreach (['views/main.php', 'views/detail.php', $pluginName . '.php'] as $rel) {
    $path = $base . $rel;
    if (!is_file($path)) { diag_row('err', $rel . ' 不存在'); continue; }
    diag_row('ok', $rel . '(' . number_format(filesize($path)) . ' 字节)');
    if (function_exists('exec')) {
        $out = []; $ret = 0;
        @exec('php -l ' . escapeshellarg($path) . ' 2>&1', $out, $ret);
        diag_row($ret === 0 ? 'ok' : 'err', '&nbsp;&nbsp;└ ' . ($ret === 0 ? '语法通过' : implode(' ', $out)));
    }
}

echo '<h2>阶段 5 · 错误日志</h2>';
foreach (['content/backup/plugin-error.log', 'content/backup/hook-error.log', 'content/backup/route-error.log'] as $lf) {
    $path = LITELOG_ROOT . $lf;
    if (!is_file($path) || filesize($path) === 0) continue;
    diag_row('info', $lf);
    $lines = explode("\n", trim(@file_get_contents($path)));
    echo '<pre>' . htmlspecialchars(implode("\n", array_slice($lines, -15))) . '</pre>';
}

echo '<h2>✅ 诊断完成</h2><p>请把整页内容截图发给我。</p><p>⚠️ 用完请删除 _diag_plugin.php</p>';
?>
</body></html>

关键点:while (ob_get_level() > 0) @ob_end_clean(); 先清空缓冲;define('LITELOG_ROOT', DIR . DIRECTORY_SEPARATOR) 必须先定义,否则 init.php 会 exit('Access Denied');$_GET['force_view'] = 'desktop' 避免 mobile router 提前 exit。

14.4 三点式日志探针

在关键位置插入只写日志、不输出的探针:

@file_put_contents(
    CONTENT_PATH . 'backup/probe.log',
    '[' . date('Y-m-d H:i:s') . '] 标记 ①' . "\n",
    FILE_APPEND | LOCK_EX
);

三点布设:① {插件名}.php 主文件;② 控制器方法开头;③ 视图文件顶部。

结果解读:

probe.log 内容含义下一步
文件不存在请求没到插件dispatcher 路由没匹配
只有 ①主文件加载了,控制器没被调用dispatch_plugin_route 没触发
有 ① + ②控制器进入,视图没执行控制器内部崩了
有 ① + ② + ③视图开始执行,崩在视图内部视图有语法或运行时错误
只有 ①,重复 20 次插件被重复加载有文件循环 require

14.5 性能探针(P1-P5 红线验证)

在插件主文件加可选的性能探针(生产环境删除):

if (!function_exists('mp_perf_tick')) {
    function mp_perf_tick($label) {
        static $t0 = null;
        if ($t0 === null) $t0 = microtime(true);
        $dt = (microtime(true) - $t0) * 1000;
        @file_put_contents(
            CONTENT_PATH . 'backup/perf.log',
            sprintf("%.2fms  %s\n", $dt, $label),
            FILE_APPEND | LOCK_EX
        );
    }
}

在关键位置调用 mp_perf_tick('主文件加载完')、mp_perf_tick('路由匹配完')、mp_perf_tick('视图渲染完'),观察日志。

上线前验证:单次请求所有 mp_perf_tick 之和 < 30ms;SHOW PROCESSLIST 观察单次请求 SQL 数 < 10 条;memory_get_peak_usage() 观察内存增量 < 5MB。

14.6 通用验证手法

手法用途
php -l语法检查
_diag_plugin.php综合诊断
三点式日志探针白屏定位
var_dump($x); exit;短路调试
mp_perf_tick()性能定位
F12 Network前端请求 URL / 状态码

全角标点检查:grep -rnE '\$[a-zA-Z_][a-zA-Z0-9_]*[():;,。!?]' content/plugins/{插件名}/


第 15 章 完整最小骨架

15.1 目录结构

content/plugins/my_plugin/
├── my_plugin.php
├── my_plugin_callback.php
├── my_plugin_setting.php
├── frontend/routes.php
├── views/main.php
└── assets/{style.css, script.js}

15.2 my_plugin.php(主文件)

<?php
/**
 * Plugin Name: 我的插件
 * Description: 一句话描述功能
 * Version: 1.0.0
 * Author: 你的名字
 * Author URI: https://example.com
 */

if (!defined('LITELOG_ROOT')) exit('error!');
if (!defined('MP_VER')) define('MP_VER', '1.0.0');

if (!function_exists('MP_conf')) {
    function MP_conf($key, $default = '') {
        if (!class_exists('Option')) return $default;
        try { $v = Option::get('my_plugin_' . $key, ''); }
        catch (Throwable $e) { return $default; }
        return $v !== '' ? $v : $default;
    }
}
if (!function_exists('MP_isEnabled')) {
    function MP_isEnabled() { return MP_conf('enabled', '1') === '1'; }
}

/* ★ 通用 POST 按钮生成器 */
if (!function_exists('MP_postBtn')) {
    function MP_postBtn($action, array $params, $label, $confirm = '', $class = '') {
        $qs = http_build_query(array_merge(['action' => $action], $params));
        $formAction = BLOG_URL . '?' . $qs;
        $on = '';
        if ($confirm !== '') {
            $jsonConfirm = json_encode((string)$confirm,
                JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP);
            $on = ' onsubmit="return confirm(' . htmlspecialchars($jsonConfirm, ENT_QUOTES, 'UTF-8') . ')"';
        }
        $cls = $class !== '' ? ' class="' . htmlspecialchars($class, ENT_QUOTES, 'UTF-8') . '"' : '';
        return '<form method="post" action="' . htmlspecialchars($formAction, ENT_QUOTES, 'UTF-8') . '" style="display:inline"' . $on . '>'
             . (class_exists('Csrf') ? Csrf::field() : '')
             . '<button type="submit"' . $cls . ' style="background:none;border:0;padding:0;cursor:pointer;color:inherit;font:inherit">' . $label . '</button></form>';
    }
}

/* Hook 注册 */
if (class_exists('Hook')) {
    try {
        Hook::addAction('layout_head_end', 'MP_injectCss');
        Hook::addAction('template_head',   'MP_injectCss');
        Hook::addAction('layout_footer_scripts', 'MP_injectJs');
        Hook::addAction('body_end',              'MP_injectJs');
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin hook register: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}
if (!function_exists('MP_injectCss')) {
    function MP_injectCss() {
        static $done = false; if ($done) return; $done = true;
        if (empty($GLOBALS['__mp_active'])) return;
        $ver = (int)(@filemtime(__DIR__ . '/assets/style.css') ?: 1);
        echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/assets/style.css?v=' . $ver . '">' . "\n";
    }
}
if (!function_exists('MP_injectJs')) {
    function MP_injectJs() {
        static $done = false; if ($done) return; $done = true;
        if (empty($GLOBALS['__mp_active'])) return;
        $ver = (int)(@filemtime(__DIR__ . '/assets/script.js') ?: 1);
        echo '<script src="' . BLOG_URL . 'content/plugins/my_plugin/assets/script.js?v=' . $ver . '"></script>' . "\n";
    }
}

/* 加载路由文件 */
if (class_exists('PluginRegistry')) {
    try {
        $__routes = __DIR__ . '/frontend/routes.php';
        if (is_file($__routes)) require_once $__routes;
    } catch (Throwable $e) { /* 写日志 */ }
}

15.3 my_plugin_callback.php

见 §3.3。

15.4 frontend/routes.php

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;

try {
    PluginRegistry::addFrontendRoute('my_plugin/?', function ($matches) {
        if (!function_exists('MP_isEnabled') || !MP_isEnabled()) {
            header('HTTP/1.0 404 Not Found');
            if (class_exists('View')) View::display('404');
            return;
        }
        $GLOBALS['__mp_active'] = true;
        include __DIR__ . '/../views/main.php';
    });
} catch (Throwable $e) { /* 写日志 */ }

15.5 views/main.php

<?php
if (!defined('LITELOG_ROOT')) exit('error!');

if (!function_exists('MP_isEnabled')) {
    echo '<div style="max-width:600px;margin:80px auto;padding:20px;text-align:center;'
       . 'border:1px solid #fca5a5;border-radius:8px;color:#b91c1c;background:#fef2f2;font-family:sans-serif">'
       . '插件未正确加载</div>';
    return;
}

/* 三变量兜底 */
$page_title  = '我的插件 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

/* JS 强制覆盖 document.title */
if (empty($GLOBALS['__mp_title_hooked'])) {
    $GLOBALS['__mp_title_hooked'] = true;
    $__mp_title = $page_title;
    if (class_exists('Hook')) {
        try {
            $__mp_js = '<script>(function(){var t='
                . json_encode($__mp_title, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)
                . ';if(document.title!==t){document.title=t;}})();</script>' . "\n";
            Hook::addAction('layout_head_end', function () use ($__mp_js) {
                if (empty($GLOBALS['__mp_active'])) return;
                echo $__mp_js;
            });
            Hook::addAction('template_head', function () use ($__mp_js) {
                if (empty($GLOBALS['__mp_active'])) return;
                echo $__mp_js;
            });
        } catch (Throwable $e) {}
    }
}

include View::resolve('header');
?>
<div class="mp-container">
    <h1>我的插件</h1>
    <form method="post" action="<?= BLOG_URL ?>?action=my_plugin_ajax">
        <?= Csrf::field() ?>
        <input type="text" name="name" placeholder="名称">
        <button type="submit">提交</button>
    </form>
</div>
<?php include View::resolve('footer'); ?>

15.6 my_plugin_setting.php

见 §6.3。

15.7 assets/style.css / assets/script.js

空文件也必须存在(否则 filemtime() 会 warning)。


第 16 章 交付前自检清单

16.1 结构

  • [ ] 目录名 = 主文件名(小写下划线)
  • [ ] 插件名不与真实目录 / 核心前缀冲突(§2.3)
  • [ ] 有 {插件名}.php + {插件名}_callback.php
  • [ ] 有 frontend/routes.php(如需要)
  • [ ] 有 views/ 目录
  • [ ] 有 assets/ 目录(哪怕空文件)
  • [ ] 有 {插件名}_setting.php(如需设置页)
  • [ ] data/ 目录有 .htaccess

16.2 安全

  • [ ] 所有 SQL 用占位符
  • [ ] 所有输出过 h()
  • [ ] 无文件操作,或过 basename()
  • [ ] AJAX 写操作有登录校验,且先登录后 CSRF
  • [ ] 原子扣减用 query()->rowCount()
  • [ ] 无 unserialize / eval
  • [ ] IN (...) 用 array_map('intval') + 占位符
  • [ ] 未覆盖 PDO 属性
  • [ ] 所有 POST 表单有 Csrf::field()
  • [ ] 所有 AJAX 带 X-CSRF-Token
  • [ ] 后台入口调 Auth::requireCap()
  • [ ] 所有表单 action 是绝对 URL

16.3 ⭐ 500 免疫

  • [ ] 主文件每个 define() 有 if (!defined(...))
  • [ ] 主文件每个 function 有 if (!function_exists(...))
  • [ ] 所有 Option::get() 第二参数是 ''
  • [ ] callback_init() 整段 try/catch,DDL 无 COMMENT / COLLATE
  • [ ] 视图顶部有 function_exists() 守卫
  • [ ] 所有 Hook::addAction() 包在 class_exists('Hook') + try/catch
  • [ ] PluginRegistry 调用前有 class_exists() 守卫
  • [ ] 主文件顶层无副作用(P1)
  • [ ] 每个 .php 文件都通过 php -l

16.4 ⭐ 视图与资源

  • [ ] 前台视图 include View::resolve('header') 和 footer
  • [ ] 前台视图设置三变量 $page_title / $title / $__pageTitle
  • [ ] 前台视图不自建 <!DOCTYPE html>
  • [ ] CSS/JS 走 layout_head_end / layout_footer_scripts Hook 注入(同时注册 template_* 兼容老主题)
  • [ ] 资源 URL 拼 BLOG_URL,带 ?v=filemtime 或 ?v=版本号
  • [ ] PHP 标签合并,避免连续两个 <?php
  • [ ] 钩子里用 h() 而非 $this->e()(§7.4)

16.5 ⭐ 性能(P1-P5 红线)

  • [ ] P1:主文件顶层无查库 / IO / HTTP / 重循环 / Option::set
  • [ ] P2:每个 Hook 回调执行 < 5ms,第一行快速短路
  • [ ] P3:单次请求所有 Hook 的 SQL 总数 ≤ 5 条
  • [ ] P4:无 N+1 查询(循环内无 fetchOne / fetchAll)
  • [ ] P5:无 SELECT * 拉全表;无 ORDER BY RAND()
  • [ ] 单次请求读 ≥ 3 个 option,用 Option::preload() 批量预取
  • [ ] 每请求都读的 option 设了 autoload='y',偶尔读的用默认 'n'
  • [ ] 自定义 Widget 声明了 $cacheable
  • [ ] Widget render() 内无 N+1 查询
  • [ ] 大表查询有分页 / 限行
  • [ ] AJAX 写操作有限流,读操作有缓存
  • [ ] 超过 3 秒的任务已队列化
  • [ ] 曝光 / 点击统计渲染期收集,响应后批量落库
  • [ ] 关键位置加了 mp_perf_tick() 探针,单次请求总和 < 30ms
  • [ ] SHOW PROCESSLIST 观察单次请求 SQL 数 < 10 条
  • [ ] memory_get_peak_usage() 观察内存增量 < 5MB

16.6 部署卫生

  • [ ] 每个 .php 文件 UTF-8 无 BOM + LF 行尾
  • [ ] data/ 目录有 .htaccess
  • [ ] 无 .bak / .tmp / .revert 残留
  • [ ] 全角标点紧跟 $var 已检查({$var} 形式)
  • [ ] 插件名不在禁用清单
  • [ ] 卸载脚本可清理所有数据(表 / option / 缓存文件)

16.7 验证

  • [ ] 启用 → 访问 → 停用 → 再启用 → 卸载 全流程无异常
  • [ ] 浏览器标签页标题正确
  • [ ] F12 Console 无报错,Network 全 200
  • [ ] 卸载后表删除、option 清空、无残留
  • [ ] 用 _diag_plugin.php 走查一遍
  • [ ] ab -n 100 -c 10 http://your-site/my_plugin 压测 P95 < 200ms

第 17 章 三十八条铁律

#教训
1规范是地图,内核是地形。地图和地形冲突时,信地形
2不确定的机制,先用 error_log / var_dump / 读源码验证,禁止靠猜
3有可工作参考实例时,先"逐行结构对齐",再谈业务
4导出/文档没看到某物 ≠ 某物不存在;先问,再断言
5每条规范都有场景前提,问清上下文再套用
6少说"一定";多说"请验证" / "如未生效,查这里"
7"多键兜底""兼容写法"往往是懒惰的遮羞布
8视图不能自建 HTML 骨架
9CSS/JS 走 Hook 注入
10内核只自动加载主文件 + 约定路径的 register/routes;子文件要主动 require
11不要用 ob_start('callback') 做页面拦截
12ob_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()
22Option::get() 第二参数永远是 ''
23所有表单 action 必须是绝对 URL——action="?xxx" 在伪静态路径下会路由错乱
24JS 动态生成表单的 action 也必须是绝对 URL
25视图顶部同时设置 $page_title / $title / $__pageTitle——主题 header 变量名不可预测
26主题 header 硬编码时,用 JS 强制覆盖 document.title
27连续两个 <?php 是陷阱——合并为一个大 PHP 块
28诊断脚本必须先 define('LITELOG_ROOT', ...)——否则 init.php 会 exit('Access Denied')
29移动端 router.php 会抢跑并 exit——init.php 里它在 loadActivePlugins() 之前
30$_REQUEST 受 request_order 影响——显式用 $_POST / $_GET
31主文件顶层禁止任何副作用——查库、IO、HTTP、重循环必须移到 register/routes 或 controller
32Hook 回调必须 O(1) 或 O(极少),单次 < 5ms——禁止在回调里做 IO、查库、远程请求
33自定义 Widget 必须声明 $cacheable——随机/时变内容必须设为 false
34循环里禁止查询——N+1 必须改为 IN 批查或 GROUP BY
35**禁止 SELECT 拉全表*——明确列名 + LIMIT 或分页
36禁止 ORDER BY RAND()——万行以上会拖垮数据库,改用采样法
37单次请求读 ≥ 3 个 option 必须用 Option::preload() 批量预取
38超过 3 秒的任务必须队列化——前台只返回任务 ID,由 cron/CLI 消费

常见错误推理链

❌ 错误链✅ 正确链
路由不生效 → "可能 PluginRegistry 不存在" → 改用 Hook → 引入新问题读 dispatcher.php → 检查自己的注册代码 → 修复
CSRF 报错 → "可能 Csrf 类没加载" → 加 class_exists → 还是错看报错是"缺少令牌"还是"不匹配" → 缺就检查表单/AJAX 是否带 token
删除按钮没反应 → "可能权限问题" → 加日志 → 还是没反应F12 Network → 看请求 URL → 如果是 /xxx/yyy?action=... 而非 /?action=... → 相对 URL 陷阱
标题不对 → 改 $page_title → 无效 → 改 $title → 无效 → 放弃读主题 header.php → 看它读哪个变量 → 硬编码则用 JS 覆盖
白屏 → "可能数据库问题" → 检查 DB → 正常 → 清缓存 → 还白屏用 _diag_plugin.php → 阶段 4 显示 syntax error → php -l 验证 → 修复
插件区块只显示一半 → 猜模板渲染出错 → 反复改模板打开 plugin-error.log → 看到 Using $this → 钩子里用 h() 替代 $this->e()
后台保存后字段变 0 → 猜数据库问题 → 查库正常检查 (int)($_POST[$k] ?? 0) → 字段在某次表单没出现 → 改 array_key_exists 跳过
网站变慢 → "可能数据库" → 加索引 → 还是慢 → 升级 PHP → 还是慢用 mp_perf_tick() 打点 → 定位哪个 Hook/Widget 慢 → 检查 N+1 / ORDER BY RAND() → 加缓存 + 改查询

第 18 章 给 AI 的指令模板

18.1 从零开发新插件

请按 LiteLog 插件开发规范 v6.0 开发一个 XXX 插件:

【附件:LiteLog 插件开发规范 v6.0 完整版.md】

需求:
- 功能:……
- 前台入口:URL ……,action=……
- 后台入口(可选):菜单名「……」,action=……,设置页放 `{插件名}_setting.php`
- 数据表:……

严格遵守本文件所有条款,特别是:
- 第 1 章「三条硬性安全规则」+「九条红线」+「十条口诀」+「五条性能红线(P1-P5)」
- 第 2 章「目录结构与自动加载真相」+ §2.3「插件名约束」
- 第 3 章「主文件与生命周期」+ §3.4「顶层禁止副作用」
- 第 5 章「路由系统」+ §5.3「快速短路」
- 第 7 章「钩子系统」+ §7.4「钩子回调作用域」+ §7.5「快速短路」
- 第 9 章「视图与模板」(含三变量兜底 + PHP 语法安全)
- 第 11 章「CSRF 与权限」(含先登录后 CSRF)
- 第 13 章「安全与陷阱」(含相对 URL + 设置字段缺失 + 上传白名单 + 性能陷阱)
- 第 16 章「交付前自检清单」

不要修改任何核心文件。完成后给出完整文件列表和每个文件的完整代码。

18.2 排查"网站变慢"

启用我的插件后网站变慢:……

【附件:LiteLog 插件开发规范 v6.0 完整版.md】

主文件代码:……
Widget / Hook 代码:……
慢查询 / 压测结果:……

请按第 1.4 节「五条性能红线」排查:
1. 主文件顶层有无副作用(P1)
2. Hook 回调有无快速短路(P2)
3. 单次请求 SQL 总数是否 ≤ 5 条(P3)
4. 有无 N+1 查询(P4)
5. 有无 SELECT * / ORDER BY RAND()(P5)

18.3 排查 500 / CSRF / 相对 URL / 标题 / 半截输出

分别见 §14.1 表格和 §13.1、§9.2、§7.4。

18.4 排错通用模板

我的插件出现以下症状:……

【附件:LiteLog 插件开发规范 v6.0 完整版.md】

当前代码:(贴主文件 + 视图 + 路由 + 设置页)
内核相关信息:(贴 dispatcher.php / 主题 header.php)
日志:(content/backup/plugin-error.log)

请按以下顺序排查:
1. 先读内核源码确认机制(不要猜)
2. 再检查代码是否符合机制
3. 给出最小修改方案
4. 给出验证方法

如果需要诊断脚本,请直接给我一个完整的 `_diag_plugin.php`。

第 19 章 附录

附录 A · API 速查

Database

$DB->query($sql, $params)                    // PDOStatement
$DB->fetchAll($sql, $params)                 // array
$DB->fetchOne($sql, $params)                 // array | false
$DB->fetchColumn($sql, $params)              // scalar | false
$DB->insert($table, $data)                   // string lastInsertId
$DB->update($table, $data, $where, $params)  // true
$DB->delete($table, $where, $params)         // true
$DB->pdo()                                   // PDO
$DB->prefix()                                // 表前缀

Option

Option::get($key, '')             // string(第二参数永远 '')
Option::set($key, $value)         // void
Option::set($key, $value, 'y')    // 强制 autoload
Option::preload(['k1','k2'])      // 批量预取
Option::all()                     // array

Hook

Hook::addAction($hook, $func, $priority = 10)
Hook::doAction($hook, ...$args)
Hook::removeAction($hook, $func = null)
Hook::hasAction($hook)
Hook::captureAction($hook, ...$args)
Hook::applyFilters($hook, $value, ...$args)

View:View::resolve($name) / View::display($name, $data = [])

Url

Url::log($gid, $alias) / Url::sort($sid, $alias) / Url::tag($name)
Url::author($uid) / Url::record($date)
Url::isRewriteOn() / Url::isPluginRewriteOn()

Csrf:Csrf::token() / Csrf::field() / Csrf::verify() / Csrf::verifyJson($payload)

Auth:Auth::check() / Auth::id() / Auth::role() / Auth::can($cap) / Auth::requireCap($cap) / Auth::canEditLog($log) / Auth::regenerate()

PluginRegistry:addAdminPage / addAdminMenu / addFrontendRoute / addAdminAsset / enqueueAsset / renderAdminPage / renderPluginSetting

Output:Output::capture($callable, ...$args) / Output::start($name) / Output::end($name) / Output::wrap($name, $callable, ...$args) / Output::discard($name) / Output::level()

辅助函数:h($s) / redirect($url) / isLoggedIn() / checkLogin() / dateFmt($ts) / datetimeFmt($ts) / getExcerpt($html, $len) / makeAlias($str) / pagination($page, $total, $perPage) / getPageNumber()

附录 B · 命名约定

符号命名示例
函数{前缀}_{CamelCase}ICG_isEnabled()
常量{前缀}_UPPERICG_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 · 性能检查卡(打印贴显示器)

□ P1 主文件顶层有查库/IO/HTTP/重循环/Option::set 吗?   → 挪到 register/routes/controller
□ P2 每个 Hook 回调第一行快速短路了吗?                 → if (条件不符) return;
□ P2 Hook 回调有 DB 查询吗?                           → 加缓存或挪到 AJAX
□ P3 单次请求所有 Hook 的 SQL 总数 ≤ 5 条吗?          → 查 PROCESSLIST
□ P4 render() 里有 foreach + 查询吗?                  → GROUP BY 一次查
□ P5 SELECT * 了吗?                                   → 明确列名
□ P5 ORDER BY RAND() 了吗?                            → 采样法
□ Option::get 单次请求 ≥ 3 次了吗?                     → preload()
□ 随机 Widget 声明 cacheable=false 了吗?
□ 循环里调 Hook / DB / 远程了吗?                       → 全部挪出去
□ 一次请求 SQL > 10 条了吗?                           → 查 PROCESSLIST
□ 一次请求内存增量 > 5MB 了吗?                         → 查 memory_get_peak_usage
□ 曝光/点击统计逐条写库了吗?                           → 渲染期收集,响应后批量
□ > 3 秒任务同步执行了吗?                              → 队列化
□ Widget 数量 > 20 个了吗?                             → flushCache 会变慢

附录 D · 案例集

案例 1:相对 URL 陷阱导致删除按钮失效

  • 症状:论坛插件点击"删除主题"按钮,页面刷新但帖子还在。
  • 排查:F12 Network → 请求 URL 是 POST /forum/thread/5?action=forum_delete&id=5 → 表单 action="?action=forum_delete&id=5" 是相对 URL。
  • 修复:改为 action="<?= BLOG_URL ?>?action=forum_delete&id=5"。
  • 教训:见 §13.1。

案例 2:标题不显示插件名

  • 症状:访问 /daily_article/detail/5,浏览器标签显示站点名。
  • 排查:主题 header.php 读 $page_title(已设置);诊断脚本发现 View::resolve('header') 返回 mobile/header.php(Cookie litelog_view=mobile);移动端 header 读 $__pageTitle(未设置)。
  • 修复:三变量都设置 + JS 覆盖 document.title。
  • 教训:见 §9.2。

案例 3:视图白屏无错误

  • 症状:访问 /daily_article/detail/5 页面完全空白。
  • 排查:诊断脚本阶段 4 显示 syntax error on line 17 → 打开文件发现连续两个 <?php。
  • 修复:合并为一个 PHP 块。
  • 教训:见 §9.5。

案例 4:插件路由 404

  • 症状:访问 /daily_article/detail/5 显示博客首页列表。
  • 排查:读 dispatcher.php 确认 v2.1 已内置 matchPluginUri(),但当前 plugin_rewrite 开关关闭,或插件主文件不存在。
  • 修复:开启 plugin_rewrite 或检查插件主文件。
  • 教训:见 §5.2。

案例 5:AJAX 403 "缺少 CSRF 令牌"

  • 症状:后台发文章返回 403。
  • 排查:admin/index.php 对所有 POST 强制 Csrf::verify();插件表单漏加 Csrf::field()。
  • 修复:所有 POST 表单加 <?= Csrf::field() ?>。
  • 教训:见 §11.2。

案例 6:插件区块只显示一半

  • 症状:钩子渲染的区块前半截正常,后半截消失,HTTP 200。
  • 排查:查 plugin-error.log 看到 Using $this when not in object context;钩子里用了 $this->e()。
  • 修复:用 h() 替代。
  • 教训:见 §7.4。

案例 7:网站变慢

  • 症状:启用插件后首页从 200ms 变 800ms。
  • 排查:mp_perf_tick() 打点发现 log_list_result 回调查全表 2000 行 → 内存在 ORDER BY RAND() 上。
  • 修复:log_list_result 里快速短路(只在首页执行)+ 随机采样替代 ORDER BY RAND()。
  • 教训:见 §1.4 + §7.5 + §12.1。

附录 E · 更新日志

v6.0(2026-10-18)——终版

  • 与 LiteLog 内核 v2.1 源码逐行核对(dispatcher.php / hook.php / plugin_registry.php / function.common.php / mysql.php / option.php / csrf.php / auth.php / url.php / view.php / output.php / widget.php / admin/index.php / admin/views/layout.php / admin/upload.php)
  • 新增 §1.4「五条性能红线(P1-P5)」——一票否决
  • 新增 §7.5「钩子回调必须快速短路」
  • 新增 §13.6「性能陷阱」
  • 新增 §14.5「性能探针」
  • 新增 §16.5「性能自检(P1-P5)」
  • 新增附录 C「性能检查卡」
  • 钩子表加「触发方」分类:核心触发 / 主题触发(明确区分)
  • 修正 §2.2:admin/register.php 和 frontend/routes.php 按 context 自动加载
  • 修正 §11.5:核心登录不支持 next 参数,登录回跳由插件自行处理
  • 修正 §7.2:layout_* 和 template_* 是主题触发,需同时注册兼容
  • 合并 v3.4 规范与 v4.0 手册

v3.4(2026-10-18):新增 10 条(顶层副作用 / 插件名约束 / 登录回跳 / 钩子作用域 / 设置字段缺失 / 上传白名单 / 通用验证 / 部署卫生 / 曝光统计 / 错误信息可诊断)

v4.0(2026-10-13):手册结构重构,铁律从 22 条扩到 30 条,加入相对 URL 陷阱、三变量兜底、三点式探针、诊断脚本。

v3.3(2026-10-17):新增第 21 章性能规范,铁律从 30 条扩到 38 条,Widget $cacheable,Option::preload(),长任务队列化。


收尾

一句话总结:

「define 判 defined,function 判 exists,Option 用空串,callback 整段包,DDL 去 COMMENT,视图先守卫,Hook 也包 try,routes 要 require,PHP 标签别写乱,顶层副作用莫要犯。」

核心原则 20 条:

  1. 视图 include 主题 header/footer
  2. 资源走 layout_* Hook 注入(同时注册 template_* 兼容老主题)
  3. 不要用 ob_start('callback') 拦截页面
  4. 必须用输出缓冲时,用 Output::capture() + 长度守卫
  5. 机制不明,先读源码
  6. 所有 POST 表单加 Csrf::field(),AJAX 带 X-CSRF-Token
  7. 后台操作前调 Auth::requireCap()
  8. 插件设置页放 {插件名}_setting.php
  9. 所有表单 action 必须是绝对 URL
  10. 标题三变量兜底 + 必要时 JS 覆盖 document.title
  11. PHP 标签合并,避免连续两个 <?php
  12. 诊断脚本必须先 define('LITELOG_ROOT')
  13. 三点式日志探针法定位白屏崩在哪一步
  14. 核心 matchPluginUri() 已内置伪静态路由识别(v2.1)
  15. 移动端 router 会抢跑并 exit
  16. $_REQUEST 换成显式 $_POST / $_GET
  17. 主文件顶层禁止副作用
  18. 钩子回调第一行快速短路,单次 < 5ms
  19. 钩子回调内 $this 不可用——用 h()
  20. 单次请求 SQL 增量 ≤ 10 条、内存增量 < 5MB

掌握这 19 章 + 38 条铁律 + 5 条核心原则 + 3 条安全规则 + 9 条红线 + 5 条性能红线,99% 的插件能一次跑通,并且不会拖慢网站。


本文档基于 LiteLog 内核 v2.1 + default 主题 v1.3.1 + 每日一文 v1.2.7 修复战整理,与核心源码逐行核对(2026-10-18)。

登录 或 后台登录 后参与讨论