主站
论坛首页/ LiteLog /帖子

LiteLog 插件开发规范 v3.2

作者:zhangwei 管理员 · 5 天前 · 10 浏览 · 0 回复
zhangwei
楼主
管理员
1 楼

LiteLog 插件开发规范 v3.2

版本:3.2(2026-10-13) 性质:在 v3.1 基础上,融入一次真实插件"每日一文"完整修复战的全部经验教训。 v3.2 相对 v3.1 的核心新增: - ① 相对 URL 陷阱——本版本最重要的新增,action="?xxx" 在伪静态路径下必错(第 8 章) - ② 页面标题三变量兜底 + JS 强制覆盖——主题 header 变量名不确定时唯一可靠方案(第 10.7 节) - ③ 伪静态路由识别——/插件名/xxx 的核心 dispatcher 补丁方案(第 7.6 节) - ④ PHP 语法安全写法——避免"连续两个 <?php"和编辑器粘贴导致的 Parse error(第 5.3 节) - ⑤ 诊断脚本规范——独立诊断脚本必须前置 define('LITELOG_ROOT')(第 16.5 节) - ⑥ 三点式日志探针法——精确定位"白屏在哪个阶段崩"(第 16.6 节) - ⑦ 移动端 router 会抢跑——mobile/router.php 的 exit 会绕过一切后续 hook(第 13.6 节) - ⑧ 第 19 章铁律从 22 条扩到 30 条 核实基准:LiteLog 内核 v1.2 + default 主题 v1.3.1 + 每日一文 v1.2.7 修复战

📌 给 AI 的必读(0 号指令)

写代码前,请按顺序读完以下章节:

  1. 第 1 章《500 免疫》——违反任意一条,后台直接 500。
  2. 第 2 章《五条硬性安全规则》——★ v3.2 从四条扩为五条,新增"绝对 URL"。
  3. 第 3 章《九条最高级别红线》——一票否决。
  4. 第 4 章《目录结构与自动加载真相》。
  5. ★ 第 5.3 节《PHP 语法安全写法》——防 Parse error。
  6. ★ 第 7.6 节《伪静态路由识别》——/插件名/xxx 必须的核心补丁。
  7. ★ 第 8.5 节《相对 URL 陷阱》——v3.2 最重要新增。
  8. 第 8.6 节《输出缓冲与响应拦截》——ob_start 陷阱。
  9. 第 10 章《视图与模板》——必须 include 主题 header/footer;评论表单必须加 CSRF;标题必须三变量兜底。
  10. 第 11 章《资源加载》——CSS/JS 走 layout_* Hook 注入。
  11. ★ 第 14.4 节《AJAX 端点九条铁律》。
  12. ★ 第 16.5–16.6 节《诊断脚本 + 三点式日志探针》——排错必备。

目录


第 1 章:500 错误免疫(最高优先级)

一旦发生,整个后台 500。违反任意一条,插件即不合格。

1.1 八宗罪

罪 1 ★★★★★:主文件顶层 define() / function 无守卫

// ❌
define('ICG_VER', '1.0.0');
function ICG_isEnabled() { /* ... */ }

// ✅
if (!defined('ICG_VER')) define('ICG_VER', '1.0.0');
if (!function_exists('ICG_isEnabled')) {
    function ICG_isEnabled() { /* ... */ }
}
口诀:define 判 defined,function 判 exists。

罪 2 ★★★★:Option::get() 第二参数传 null

$v = Option::get('xxx_enabled', '');   // ★ 永远用 ''

罪 3 ★★★★:生命周期 DDL 用 COMMENT / COLLATE / 无 try/catch

function callback_init() {
    try {
        global $DB;
        $DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "my_items` (
            `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            `name` VARCHAR(100) NOT NULL DEFAULT '',
            `created_at` INT UNSIGNED NOT NULL DEFAULT 0
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin callback_init: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

关键三条:① 整段 try/catch;② DDL 无 COMMENT;③ 无 COLLATE。

罪 4 ★★★★:视图直接调用插件函数,无 function_exists 守卫

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!function_exists('ICG_isEnabled')) {
    echo '<div class="error">插件未正确加载</div>';
    return;
}

罪 5 ★★★:Hook::addAction() 无 try/catch

罪 6 ★★★:PluginRegistry 调用无 class_exists 守卫

罪 7 ★★★:frontend/routes.php / admin/register.php 被主文件遗忘

内核 loadActivePlugins() 只加载主文件,子文件必须主文件主动 require。

★ 罪 8(v3.2 新增):PHP 标签混乱导致 Parse error

这是本轮"每日一文"插件白屏的直接原因:视图文件里出现"连续两个 <?php"或"编辑器粘贴导致 <?php 被转义",会引发 syntax error, unexpected '<', expecting end of file。

详见第 5.3 节。

1.2 九条口诀汇总

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

第 2 章:五条硬性安全规则(v3.2 扩充)

规则 1 — 文件操作,无 basename 不落盘

$safeName = basename($userInput);
if ($safeName === '' || $safeName !== $userInput) return false;
$safePath = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $safeName;
if (is_file($safePath)) unlink($safePath);

规则 2 — 数据库查询,无预处理不执行

// ✅
$row = $DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=?", [(int)$gid]);

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

// ❌
$DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "blog` WHERE `gid`=$gid");

规则 3 — 输出字符串必须转义

echo '<h1>' . h($log['title']) . '</h1>';   // ✅
echo '<h1>' . $log['title'] . '</h1>';      // ❌

2.4 CSRF 保护

内核从 v1.2 起,所有后台 POST 请求、前台评论提交都强制校验 CSRF token,插件必须配合。

2.4.1 核心 API

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

2.4.2 表单必须加 CSRF token

任何 POST 表单必须:

<form method="post" action="...">
  <?php if (class_exists('Csrf')) echo Csrf::field(); ?>
  <!-- 其他字段 -->
</form>

实战教训:本轮"每日一文"插件后台 6 个表单全部漏加 Csrf::field(),导致发文章 403。核心会在 admin/index.php 里对所有 POST 强制 Csrf::verify(),除非该 action 是通过 PluginRegistry::addAdminPage() 注册的插件页(由插件自行校验)。

2.4.3 AJAX 必须带 X-CSRF-Token

后台 layout.php 已全局包装 window.fetch,同源请求自动加 header。但如果用 XMLHttpRequest 或跨域请求,需要手动加:

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

window.LITELOG_CSRF 由 layout.php 注入,所有后台页面都可以用。

2.4.4 插件 AJAX 端点的 CSRF 处理

插件自己的 ?action=xxx 端点如果走核心 public_action_xxx 钩子,不受全局 CSRF 拦截。但推荐插件也校验:

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (class_exists('Csrf')) {
        Csrf::verify();
    }
}

插件后台页面(通过 PluginRegistry::addAdminPage() 注册)跳过了核心全局 CSRF 拦截,由插件自行决定:

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (class_exists('Csrf')) Csrf::verify();
}

2.4.5 前台评论表单必须加 CSRF

这是主题改造最容易漏掉的。评论表单:

<form method="post" action="<?= BLOG_URL ?>?action=comment_post">
  <?= Csrf::field() ?>
  <input type="hidden" name="gid" value="<?= (int)$log['gid'] ?>">
  <!-- ... -->
</form>

实战教训:默认主题的 echo_log.php 里评论表单必须有 Csrf::field(),否则所有评论提交都会返回 {"ok":false,"msg":"缺少 CSRF 令牌"}。

2.4.6 CSRF 失败时的响应格式

Csrf::fail() 会自动判断请求类型:

请求类型响应
AJAX(X-Requested-With / Accept: application/json / JSON body){"ok":false,"msg":"..."}
普通表单HTML 403 页面

插件不需要特殊处理。

2.5 后台权限系统

内核 v1.2 引入 Auth 类,取代裸 isLoggedIn()。插件后台入口必须做权限校验。

2.5.1 Auth API

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

2.5.2 角色能力表

角色能力
admin*(全部)
editorlog. / comment. / sort. / tag. / widget.manage / theme.manage / plugin.manage
authorlog.own / comment.reply

2.5.3 插件后台入口的权限写法

if (class_exists('PluginRegistry')) {
    try {
        PluginRegistry::addAdminPage('my_plugin', [
            'title'    => '我的插件',
            'callback' => function () {
                if (class_exists('Auth')) Auth::requireCap('plugin.manage');
                include __DIR__ . '/pages/main.php';
            },
        ]);
    } catch (Throwable $e) { /* 写日志 */ }
}

2.5.4 前台登录校验

前台不需要 Auth,用 isLoggedIn() 即可。

2.6 绝对 URL 规则(★ v3.2 新增)

这是本轮修复战最重要的新增。请务必理解。

2.6.1 症状

论坛插件里有一个"删除帖子"按钮:

<form method="post" action="?action=forum_delete&id=5">

在 URL 为 /forum/thread/5 的页面上,浏览器会把 ?action=... 解析为:

http://li.zgzjw.top/forum/thread/5?action=forum_delete&id=5

后果:

  • 核心 dispatcher 拿到的 $uri = 'forum/thread/5'(路径部分不含 query)
  • $_GET['action'] = 'forum_delete'
  • 但某些 Nginx 重写规则会把这个 URL 当成"查看帖子 5",先执行了查看帖子的路由处理器,exit 掉了,导致删除请求根本没走到 public_action_forum_delete

更常见的坑:即使服务器没重写,action="?xxx" 在 /forum/thread/5 页面下实际提交到当前路径,如果当前路径被任何中间件拦截,一样会失败。

2.6.2 铁律

所有表单的 action 必须使用绝对 URL:BLOG_URL . '?...' 或 forum_pretty_url(...)。

2.6.3 正例 / 反例

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

<!-- ❌ 反例:空 action,会提交到当前 URL -->
<form method="post">

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

<!-- ✅ 正例:使用插件的 URL 生成函数 -->
<form method="post" action="<?= forum_pretty_url('delete/5') ?>">

2.6.4 JS 动态生成表单同样适用

// ❌ 反例
form.action = '?action=forum_thread_mod';

// ✅ 正例
form.action = '<?= BLOG_URL ?>?action=forum_thread_mod';

2.6.5 通用 POST 按钮生成器

if (!function_exists('my_post_btn')) {
    function my_post_btn($action, array $params, $label, $confirm = '', $class = '') {
        $qs = http_build_query(array_merge(['action' => $action], $params));
        $formAction = BLOG_URL . '?' . $qs;   // ★ 绝对 URL

        $on = '';
        if ($confirm !== '') {
            $jsonConfirm = json_encode((string)$confirm,
                JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP);
            $on = ' onsubmit="return confirm(' . htmlspecialchars($jsonConfirm, ENT_QUOTES, 'UTF-8') . ')"';
        }
        $cls = $class !== '' ? ' class="' . h($class) . '"' : '';

        return '<form method="post" action="' . h($formAction) . '" style="display:inline"' . $on . '>'
             . (class_exists('Csrf') ? Csrf::field() : '')
             . '<button type="submit"' . $cls . ' style="background:none;border:0;padding:0;cursor:pointer;color:inherit;font:inherit">'
             . $label
             . '</button></form>';
    }
}

第 3 章:九条最高级别红线

  1. 严禁反序列化与动态执行——禁 unserialize() / eval() / assert() / call_user_func($_GET[...])
  2. 严禁滥用绝对路径——只用 LITELOG_ROOT / CONTENT_PATH / ADMIN_PATH / INCLUDE_PATH / DIR
  3. 渲染钩子内禁止中断页面——禁 die() / exit() / header();必须 return
  4. 上传文件必须白名单 + 随机文件名——校验 getimagesize(),名用 bin2hex(random_bytes(8))
  5. 数量扣减必须原子更新——UPDATE ... WHERE stock>0 + rowCount()
  6. IN (...) 必须强转 + 占位符——array_map('intval') + ?
  7. PDO 属性不得覆盖
  8. AJAX 写操作必须有登录校验
  9. ★ v3.2 新增:所有表单/AJAX 的 URL 必须是绝对路径——BLOG_URL . '?...',禁止 ?action=xxx 相对形式

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

4.1 目录结构

content/plugins/{插件名}/
├── {插件名}.php                    ← 【必须】主文件
├── {插件名}_callback.php           ← 【可选】生命周期
├── {插件名}_setting.php            ← 【可选】设置页
├── admin/
│   ├── register.php                ← 【可选】后台注册
│   ├── pages/                      ← 后台视图
│   └── assets/                     ← 后台资源
├── frontend/
│   └── routes.php                  ← 【可选】前台路由
├── views/                          ← 前台视图
├── assets/                         ← 静态资源
└── data/                           ← 私有数据(需 .htaccess)

4.2 内核真实自动加载行为

文件自动加载?需要做什么
{插件名}.php✅ 是无
{插件名}_callback.php❌ 否内核在启用/停用时主动 require
{插件名}_setting.php❌ 否通过 ?action=plugin_setting&name=xxx 访问
frontend/routes.php❌ 否主文件必须 require_once
admin/register.php❌ 否主文件必须 require_once
views/*.php❌ 否路由回调 / 控制器主动 include
assets/*❌ 否Hook 或视图引用

第 5 章:插件主文件与头注释

5.1 标准头注释

<?php
/**
 * Plugin Name: 我的插件
 * Description: 一句话描述
 * Version: 1.0.0
 * Author: 你的名字
 * Author URI: https://example.com
 */
if (!defined('LITELOG_ROOT')) exit('error!');

5.2 主文件只做四件事

  1. 常量守卫(define + function)
  2. 定义工具函数
  3. 注册 Hook(Hook::addAction)
  4. require 子文件

禁止:顶层 echo / print / header / DB 写入 / 耗时 IO。

5.3 PHP 语法安全写法(★ v3.2 新增)

本轮修复战的直接教训:detail.php 第 17 行 syntax error, unexpected '<', expecting end of file,导致整页白屏。

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

<?php if (!defined('LITELOG_ROOT')) exit('error!'); ?>   ← 单行 PHP 块
<?php                                                     ← 又开一个
/* 大量代码 */

问题:只要用户在编辑器里粘贴时第二个 <?php 丢失,或者被转义成 &lt;?php,整个文件就会语法错乱。

实测:用户很可能从 Markdown 或富文本编辑器里复制粘贴,其中 <?php 已经被转义或损坏。

5.3.2 安全写法:合并为一个大 PHP 块

<?php
/**
 * 每日一文 · 文章详情页
 */
if (!defined('LITELOG_ROOT')) exit('error!');

/* 所有 PHP 逻辑都在这一个块里 */
$page_title = '...';
$title      = $page_title;

include View::resolve('header');
?>
<!-- 从这里开始输出 HTML -->
<div class="...">
  ...
</div>
<?php include View::resolve('footer'); ?>

只有必须输出 HTML 时才闭合 ?>,绝不出现"两个连续 PHP 块"的写法。

5.3.3 视图文件推荐骨架

<?php
/**
 * 插件名 · 视图名
 */
if (!defined('LITELOG_ROOT')) exit('error!');

/* 1. 前置守卫 */
if (!function_exists('MYPLUGIN_isEnabled')) {
    echo '<div style="padding:40px;text-align:center;color:#b91c1c">插件未加载</div>';
    return;
}

/* 2. 所有变量准备 */
$page_title  = '...';
$title       = $page_title;
$__pageTitle = $page_title;

/* 3. 数据准备 */
$data = ...;

/* 4. 输出 HTML */
include View::resolve('header');
?>
<div class="my-page">
  ...
</div>
<?php include View::resolve('footer'); ?>

5.3.4 如何验证语法正确

php -l content/plugins/your_plugin/views/detail.php
# 期望:No syntax errors detected

没有 SSH:把文件内容复制到 https://3v4l.org/ 或 https://phpcodechecker.com/ 验证。


第 6 章:生命周期(callback 回调)

6.1 内核实测行为

时机内核调用你的回调
启用插件callback_init(){插件名}_callback.php
停用插件callback_rm()同上
升级插件callback_up()(需手动触发)同上

6.2 模板

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

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

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

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

function callback_uninstall() {
    global $DB;
    try { $DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "my_items`"); } catch (Throwable $e) {}
    try { $DB->delete('option', "option_name LIKE 'my_plugin_%'"); } catch (Throwable $e) {}
}

第 7 章:PluginRegistry 注册表

7.1 完整 API

方法签名用途
addAdminPage(string $action, array $config)注册后台页面
hasAdminPage(string $action): bool是否存在
getAdminPage(string $action): ?array获取配置
addAdminMenu(array $menu)注册侧边栏菜单
getAdminMenus(): array获取所有菜单
addFrontendRoute(string $regex, callable $cb)注册前台路由
matchFrontendRoute(string $uri): ?array匹配前台路由
addAdminAsset(string $action, string $type, string $url)注册后台资源
enqueueAsset(string $type, string $url)手动暂存资源
getPendingAssets(string $type): array读取暂存资源
renderAdminPage(string $action)渲染后台页面
renderPluginSetting(string $name)渲染 {插件名}_setting.php,自动鉴权

7.2 插件设置页统一入口

设置页放在 {插件名}_setting.php,通过核心入口访问:

<?= BLOG_URL ?>admin/?action=plugin_setting&name=xxx

内核做的事:① 校验插件名合法;② 强制 Auth::requireCap('plugin.manage');③ 渲染页面;④ 自动包上 layout。

后台插件卡片会自动显示「设置」按钮。

7.3 前台路由注册

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

try {
    PluginRegistry::addFrontendRoute('icon/?', function ($matches) {
        if (!function_exists('ICG_isEnabled') || !ICG_isEnabled()) {
            header('HTTP/1.0 404 Not Found');
            if (class_exists('View')) View::display('404');
            return;
        }
        if (ICG_requireLogin() && !isLoggedIn()) {
            redirect(BLOG_URL . 'admin/?action=login');
            return;
        }
        $GLOBALS['__icg_active'] = true;
        include __DIR__ . '/../views/generator.php';
    });
} catch (Throwable $e) {
    @file_put_contents(CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] icon_generator route register: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX);
}

7.4 后台菜单注册

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

try {
    PluginRegistry::addAdminMenu([
        'id'       => 'friend_links',
        'action'   => 'friend_links',
        'title'    => '友情链接',
        'icon'     => 'external',
        'priority' => 30,
    ]);

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

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

7.6 伪静态路由识别(★ v3.2 新增)

本轮修复战的核心发现:核心 dispatcher 不认识 /插件名/xxx 形式的 URL。默认会 fallback 到 LogController::display(),显示博客首页列表。

7.6.1 症状

  • 访问 /daily_article/detail/5,显示的是博客首页列表,不是插件详情
  • 浏览器标签显示站点名,不是插件标题
  • 插件里挂的 template_head / body_end 钩子不生效

7.6.2 两种解决思路

方案 A(推荐 · 不改核心):在插件主文件里挂 dispatch_start 钩子,主动把 URI 转换成 __plugin_path:

Hook::addAction('dispatch_start', function() {
    if (!empty($_GET['__plugin_path'])) return;
    if (isset($_GET['action']))         return;

    $uri  = strtok($_SERVER['REQUEST_URI'] ?? '/', '?');
    $base = parse_url(BLOG_URL, PHP_URL_PATH) ?: '/';
    if ($base !== '/' && $base !== '' && strpos($uri, $base) === 0) {
        $uri = substr($uri, strlen($base));
    }
    $uri = trim($uri, '/');

    if (preg_match('#^daily_article(?:/(.*))?$#i', $uri, $m)) {
        $_GET['__plugin_path'] = 'daily_article';
        $_GET['__plugin_rest'] = isset($m[1]) ? trim($m[1], '/') : '';
    }
});

⚠️ 注意:dispatch_start 钩子时机可能晚于 mobile/router.php(后者在 init.php 里抢先加载),如果命中 exit,这个钩子根本不会执行。所以此方案在移动端不完全可靠。

方案 B(彻底 · 改核心 dispatcher):让核心主动识别 /插件名/xxx。

在 include/lib/dispatcher.php 的 dispatch() 方法开头,Hook::doAction('dispatch_start'); 之后,插入:

/* ============================================================
 * ★ 自动识别 /插件名/xxx 形式 → 转为 __plugin_path
 * ============================================================ */
$uri  = strtok($_SERVER['REQUEST_URI'] ?? '/', '?');
$base = parse_url(BLOG_URL, PHP_URL_PATH) ?: '/';
if ($base !== '/' && $base !== '' && strpos($uri, $base) === 0) {
    $uri = substr($uri, strlen($base));
}
$uri = trim($uri, '/');

if (empty($_GET['__plugin_path']) && !isset($_GET['action']) && $uri !== '') {
    // 排除核心已识别的 URL 前缀
    $corePrefixes = ['post/', 'sort/', 'tag/', 'author/', 'record/', 'page/'];
    $isCore = false;
    foreach ($corePrefixes as $p) {
        if (strpos($uri, $p) === 0) { $isCore = true; break; }
    }
    if (!$isCore && preg_match('#^([a-zA-Z0-9_-]+)(?:/(.*))?$#', $uri, $m)) {
        $pluginName = $m[1];
        $pluginRest = isset($m[2]) ? trim($m[2], '/') : '';
        $mainFile = CONTENT_PATH . 'plugins/' . $pluginName . '/' . $pluginName . '.php';
        if (is_file($mainFile)) {
            $_GET['__plugin_path'] = $pluginName;
            $_GET['__plugin_rest'] = $pluginRest;
        }
    }
}

优点:

  • 所有走 dispatch_plugin_route 钩子的插件自动获得伪静态支持
  • 时机早于任何 exit,不会被 mobile router 抢跑
  • 生命周期完整(dispatch_end 正常触发)

风险:

  • 修改了核心文件,插件开发者需知会站点管理员
  • 一个插件目录名如果和某 URL 前缀同名(如某插件叫 post),会被核心前缀排除规则挡掉

7.6.3 插件端配合(无论用哪个方案)

插件的路由分发钩子里,按 $rest 二次分发:

Hook::addAction('dispatch_plugin_route', function($pluginName, $rest) {
    if ($pluginName !== 'daily_article') return;
    $GLOBALS['__plugin_routed'] = true;
    $rest = trim((string)$rest, '/');

    if ($rest === '' || $rest === 'list') { (new DailyController())->displayIndex(); return; }
    if ($rest === 'archive')               { (new DailyController())->displayArchive(); return; }
    if (preg_match('|^detail/(\d+)$|', $rest, $m)) {
        $_GET['id'] = (int)$m[1];
        (new DailyController())->displayDetail((int)$m[1]);
        return;
    }
    // ...

    $GLOBALS['__plugin_routed'] = false;
});

注意:每个分发分支最后必须 exit 或 return,且 $GLOBALS['__plugin_routed'] = true 要在最前面设置。


第 8 章:钩子系统全览

8.1 Hook API

Hook::addAction($hook, $func, $priority = 10);   // 注册(越小越先执行)
Hook::doAction($hook, ...$args);                 // 触发
Hook::removeAction($hook, $func = null);         // 移除
Hook::hasAction($hook);                          // 是否挂载
Hook::captureAction($hook, ...$args);            // 捕获输出
Hook::applyFilters($hook, $value, ...$args);     // 过滤器

两种语义:

  • Action:doAction 通知型,返回值忽略;改数据用引用传参 function (&$data, ...)
  • Filter:applyFilters 第一个参数是值,返回值替换;推荐新代码用 filter

8.2 生命周期钩子

钩子名触发点参数
plugin_activate插件启用后$name
plugin_deactivate插件停用后$name
plugin_uninstall插件卸载$name

8.3 前台模板钩子

主题布局锚点 layout_*

钩子名触发点推荐用途
layout_head_start<head> 最前早期 meta / preload
layout_head_end</head> 前加载 CSS、注入 <link>
layout_body_start<body> 之后、<header> 之前顶部横幅 / 公告条
layout_content_start<main> 之后、内容前面包屑 / 页面标题栏
layout_content_end内容后、</main> 前相关文章
layout_footer_scripts</body> 前的脚本区加载 JS / 统计代码
layout_body_end</body> 之前底部 DIY / 悬浮按钮

视图钩子

钩子名触发点参数
view_before模板渲染前$name, $data
view_after模板渲染后$name, $data

传统模板钩子(兼容)

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

前台分发钩子

钩子名触发点参数
dispatch_start分发开始无
dispatch_end分发结束(shutdown 兜底)无
dispatch_matched路由匹配后&$matched
dispatch_plugin_route__plugin_path 分发$pluginName, $rest
public_action_xxx?action=xxx 分发前无

8.4 内容与评论钩子

钩子名触发点参数
log_list_query列表查询前&$cond, $page, $perPage
log_list_result列表查询后&$logs, $page, $total
log_display_before / log_display_after详情渲染前后$log
log_content正文输出$log
log_save_before / log_save_after文章保存&$data / $gid
comment_save_before评论提交前&$data
comment_save_after评论插入后$cid, $data
comment_section_before / comment_section_after评论区前后$log

8.5 相对 URL 陷阱(★ v3.2 最重要)

结论已在第 2.6 节给出。这里补充实战细节。

8.5.1 论坛插件的"删除帖子"故障复盘

// 原来的代码
return '<form method="post" action="?' . forum_h($qs) . '" ...>'

故障链条:

  1. 帖子页面 URL:http://li.zgzjw.top/forum/thread/5
  2. 表单 HTML:<form method="post" action="?action=forum_delete&id=5">
  3. 浏览器解析 action="?xxx" → 当前路径 + query:http://li.zgzjw.top/forum/thread/5?action=forum_delete&id=5
  4. 请求发出后,服务器(Nginx + LiteLog 重写)优先匹配了 /forum/thread/5 的路由
  5. Forum_dispatchPluginRoute('forum', 'thread/5') 被调用
  6. $_GET['action'] = 'forum_delete',但 __plugin_path 也已是 forum,$rest = 'thread/5'
  7. 路由到 Forum_routeThread(),显示帖子详情(或者根本不执行删除)
  8. redirect() 后回到同页面 → 用户看到帖子还在

8.5.2 修复后

$formAction = BLOG_URL . '?' . $qs;   // 绝对 URL
return '<form method="post" action="' . forum_h($formAction) . '" ...>'

生成的 HTML:

<form method="post" action="http://li.zgzjw.top/?action=forum_delete&id=5">

浏览器完全不会把它解析成当前路径,请求直达核心的 public_action_forum_delete。

8.5.3 兜底:即使绝对 URL 也没生效

在插件的路由处理器里,显式从 $_POST 和 $_GET 分别读,不依赖 $_REQUEST(受 request_order 配置影响):

$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);

8.6 输出缓冲与响应拦截

8.6.1 六条铁律

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

8.6.2 优先用 layout_* 锚点

99% 的插件不需要碰 ob_start。想往页面上放内容?用 layout_*:

if (class_exists('Hook')) {
    try {
        Hook::addAction('layout_body_start', function () {
            echo '<div class="my-banner">欢迎访问</div>';
        });
        Hook::addAction('layout_body_end', function () {
            echo '<div class="my-floating-help">...</div>';
        });
    } catch (Throwable $e) { /* 写日志 */ }
}

8.6.3 Output 类 API

$html = Output::capture(function () use ($data) {
    echo '<ul>';
    foreach ($data as $row) echo '<li>' . h($row) . '</li>';
    echo '</ul>';
});

Output::start('my_region');
echo '...';
$html = Output::end('my_region');

$html = Output::wrap('my_block', function () {
    echo '...';
});

第 9 章:数据库与模型

9.1 Database 类精确签名

方法返回值
query($sql, $params = [])PDOStatement(可 ->rowCount())
fetchAll($sql, $params = [])array(空时 [])
fetchOne($sql, $params = [])array \| false ⚠️
fetchColumn($sql, $params = [])标量 \
insert($table, $data)lastInsertId 字符串(需 (int))
update($table, $data, $where, $whereParams = [])true(不是行数)
delete($table, $where, $params = [])true

三大陷阱:

  1. fetchOne() 失败返回 false(不是 null)
  2. insert() 返回字符串 lastInsertId——必须 (int)$newId
  3. update() / delete() 返回 true——要行数用 query()->rowCount()

原子扣减

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

9.2 核心表清单

{DB_PREFIX}user

uid(PK) / username / nickname / email / password / role

{DB_PREFIX}blog

gid(PK) / title / content / content_md / alias / sortid / author / date / views / comnum / hide('y'/'n') / type('blog'/'page') / top(TINYINT(1)) / cover / excerpt

{DB_PREFIX}comment

cid(PK) / gid / pid / poster / email / url / comment / ip / agent / date / hide('y'待审/'n'通过)

{DB_PREFIX}sort

sid(PK) / sortname / alias / pid / description

{DB_PREFIX}option

id(PK) / option_name(UNIQUE) / option_value(TEXT,永远是字符串) / autoload

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

9.3 $DB 使用示例

global $DB;

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

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

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

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

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

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

// 判断命中
$stmt = $DB->query("UPDATE ... WHERE uid = ? AND credit > 0", [(int)$uid]);
if ($stmt->rowCount() === 0) { /* 未命中 */ }

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

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

9.4 核心模型

模型主要方法
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()

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

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

$page_title  = '图标生成器 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

include View::resolve('header');
?>

<div class="my-container">
    ... 页面内容 ...
</div>

<?php include View::resolve('footer'); ?>

原理:View::resolve('header') 解析到当前主题的 header.php,它已经输出了骨架。

10.2 前台评论表单必须加 CSRF

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

10.3 视图顶部守卫模板

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

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

$page_title  = '我的插件 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

include View::resolve('header');
?>
<!-- 内容 -->
<?php include View::resolve('footer'); ?>

10.4 后台视图

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

10.5 输出转义

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

10.6 后台可用核心 CSS 类

.card / .card-title / .btn / .btn-outline / .btn-danger / .btn-sm / .table-wrap / .table / .form / .field / .tag / .empty / .error / .pagination

10.7 页面标题三变量兜底(★ v3.2 新增)

本轮修复战最顽固的问题:无论怎么改插件视图的 $page_title,浏览器标签始终显示站点名。

10.7.1 问题根源

主题有多套 header:

环境加载文件读取变量
PCtemplates/default/header.php可能是 $page_title / $title / 硬编码站点名
移动端templates/default/mobile/header.php通常是 $__pageTitle

再加上:isMobileDevice() 判断优先级是 ?force_view → Cookie litelog_view → UA,PC 浏览器也可能因为 Cookie 命中而走移动端模板。

10.7.2 铁律:视图顶部同时设置三个变量

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

/* ★ 三变量兜底:PC 端、移动端、v3.2 规范 */
$page_title  = '每日一文 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

include View::resolve('header');
?>
  • $page_title:主流通用变量名
  • $title:v3.2 规范要求(部分主题读这个)
  • $__pageTitle:移动端模板专用

10.7.3 主题端兼容(如果你能改主题)

templates/default/header.php 里:

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

/* ★ 多变量兜底:$page_title → $title → $__pageTitle → 站点名 */
$__final_title = '';
if (isset($page_title) && $page_title !== '') {
    $__final_title = $page_title;
} elseif (isset($title) && $title !== '') {
    $__final_title = $title;
} elseif (isset($__pageTitle) && $__pageTitle !== '') {
    $__final_title = $__pageTitle;
} else {
    $__final_title = Option::get('site_title', '');
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title><?= h($__final_title) ?></title>

templates/default/mobile/header.php 里类似处理。

10.7.4 终极方案:JS 强制覆盖 document.title

当主题 header 硬编码站点名、或者变量名不可预测时,从视图顶部挂 layout_head_end 钩子注入 JS:

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

/* 标题 */
$page_title  = '每日一文 - ' . Option::get('site_title', '');
$title       = $page_title;
$__pageTitle = $page_title;

/* ★ 终极方案:JS 强制覆盖 document.title */
if (empty($GLOBALS['__da_title_hooked'])) {
    $GLOBALS['__da_title_hooked'] = true;
    $__da_title = $page_title;

    if (class_exists('Hook')) {
        try {
            $__da_js = '<script>(function(){var t='
                . json_encode($__da_title, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)
                . ';if(document.title!==t){document.title=t;}})();</script>' . "\n";

            Hook::addAction('layout_head_end', function () use ($__da_js) {
                if (empty($GLOBALS['__da_active'])) return;
                echo $__da_js;
            });
            Hook::addAction('template_head', function () use ($__da_js) {
                if (empty($GLOBALS['__da_active'])) return;
                echo $__da_js;
            });
        } catch (Throwable $e) {}
    }
}

include View::resolve('header');
?>

为什么这样绝对有效:

主题 header 读什么效果
$page_title✅ 已经对
$title✅ 已经对
$__pageTitle✅ 已经对
硬编码站点名✅ JS 覆盖 document.title
什么都不输出✅ 有 <title> 才覆盖
用了缓存/CDN✅ 只影响客户端显示,绕过服务端缓存

关键设计:

  • 只在 __da_active 标记时注入,不污染其它页面
  • 用 static 或全局标志位防重复注入
  • 用 json_encode 转义标题,防 XSS

第 11 章:资源加载

11.1 前台资源用 layout_* 注入

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

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

视图里只需标记:

$GLOBALS['__mp_active'] = true;
include __DIR__ . '/../views/main.php';

兼容老主题

两个都注册,用 static 去重:

Hook::addAction('layout_head_end', 'MP_injectCss');
Hook::addAction('template_head',   'MP_injectCss');   // 老主题兜底

11.2 后台资源

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

11.3 数据目录保护

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

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

第 12 章:小工具与首页模式扩展

12.1 自定义小工具

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
  ↓
★ isMobileDevice() 判断 → 加载 mobile/router.php(可能 exit)
  ↓
loadActivePlugins() → 加载插件主文件(注册钩子)
  ↓
Dispatcher::dispatch()
  ├─ Hook::doAction('dispatch_start')
  ├─ 插件通用路由(__plugin_path)
  ├─ 标准 Rewrite URL 解析
  ├─ PluginRegistry::matchFrontendRoute()
  ├─ public_action_{action}
  ├─ dispatch_matched
  ├─ 控制器执行 → View::display()(触发 view_before / view_after)
  └─ dispatch_end(有 shutdown 兜底)

13.2 会话安全

内核 v1.2 已强化:

项值
session_regenerate_id(true)登录成功后调用
Cookie HttpOnlytrue
Cookie SameSiteLax
Cookie SecureHTTPS 自动开启
use_strict_mode1
Session 名LITELOG_SID

13.3 exit / header / die 边界表

场景exitheader
主文件顶层❌❌
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()
常量{前缀}_UPPERICG_VER
类{PrefixCamel} / {Prefix_Camel}Widget_IconGen
表名{DB_PREFIX}{插件名}_{表名}lt_icon_generator_items
Option 键{插件名}_{键名}icon_generator_enabled

核心已占用:Forum_* / UserGroup_* / Uc_* / Widget_* / HomepageMode_* / DIY_* / Output / Csrf / Auth

13.5 路径常量与斜杠

常量结尾斜杠
LITELOG_ROOT✅ 带
CONTENT_PATH✅ 带
ADMIN_PATH✅ 带
INCLUDE_PATH✅ 带
BLOG_URL✅ 带
DB_PREFIX❌ 无
// ✅
$file = CONTENT_PATH . 'plugins/my_plugin/data.json';
$url  = BLOG_URL . 'content/plugins/my_plugin/assets/admin.css';

// ❌ 多一个斜杠
$url  = BLOG_URL . '/content/plugins/...';

13.6 移动端 router 会抢跑(★ v3.2 新增)

13.6.1 机制

init.php 里的执行顺序:

define('BLOG_URL', ...);

// ★ 移动端 router 检查(先于插件)
if (function_exists('isMobileDevice') && isMobileDevice()) {
    $__mobileRouter = CONTENT_PATH . 'templates/' . getCurrentTemplateName() . '/mobile/router.php';
    if (is_file($__mobileRouter)) {
        try {
            require_once $__mobileRouter;
        } catch (Throwable $__e) { /* 写日志 */ }
    }
}

// ★ 加载插件
loadActivePlugins();

mobile/router.php 里有很多 exit 分支——一旦命中并 exit,loadActivePlugins() 根本不会执行,插件注册的所有钩子都不会生效。

13.6.2 影响

  • 移动端访问插件页面 → 插件钩子没注册 → 404 或显示博客首页
  • 前台资源注入(CSS / JS)不生效
  • 插件路由(dispatch_plugin_route)不触发

13.6.3 应对

方案 A:插件自己不依赖 mobile/router.php。所有路由都用 PluginRegistry::addFrontendRoute() 注册,它是 dispatch() 内部匹配的,不受 mobile router 影响。

方案 B:让 mobile/router.php 支持"未匹配时 fallthrough"(return 而不是 exit)。默认主题的 router 已经这么做了,但某些插件(如论坛)可能在里面有 exit 分支。

方案 C:核心 dispatcher 补丁(第 7.6 节方案 B),在 dispatch_start 后立即识别 /插件名/xxx,此时机在所有 router 之后。

13.6.4 排错

看到 content/backup/mobile-router-error.log 有记录,说明 mobile router 出了问题。查看这个日志。


第 14 章:API 参考

14.4 AJAX 端点九条铁律

  1. 第一行 header('Content-Type: application/json; charset=utf-8')
  2. 登录校验
  3. CSRF 校验
  4. 方法校验(写操作拒绝 GET)
  5. 参数强转 + 范围校验
  6. DB 操作整段 try/catch
  7. 事务内先 inTransaction() 再 rollBack()
  8. 异常不逃逸(catch 里必须 echo json_encode(...) 再 exit)
  9. exit 收尾

标准 AJAX 端点模板

if (!function_exists('MP_handleAjax')) {
    function MP_handleAjax() {
        header('Content-Type: application/json; charset=utf-8');

        // 1) 登录校验
        if (empty($_SESSION['uid'])) {
            echo json_encode(['ok' => false, 'msg' => '请先登录'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $uid = (int)$_SESSION['uid'];

        // 2) 方法校验
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            echo json_encode(['ok' => false, 'msg' => '方法不允许'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        // 3) CSRF 校验
        if (class_exists('Csrf')) {
            $sent = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? $_POST['_token'] ?? '';
            if ($sent === '' || !hash_equals(Csrf::token(), $sent)) {
                echo json_encode(['ok' => false, 'msg' => 'CSRF 校验失败,请刷新重试'], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }

        // 4) 参数校验
        $itemId = (int)($_POST['item_id'] ?? 0);
        $points = (int)($_POST['points'] ?? 0);
        if ($itemId <= 0 || $points <= 0) {
            echo json_encode(['ok' => false, 'msg' => '参数错误'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        global $DB;
        $pdo = null;
        try {
            // ... 业务 ...
            echo json_encode(['ok' => true, 'msg' => '成功'], JSON_UNESCAPED_UNICODE);
            exit;
        } catch (Throwable $e) {
            try {
                if ($pdo && $pdo->inTransaction()) $pdo->rollBack();
            } catch (Throwable $ignored) {}
            @file_put_contents(
                CONTENT_PATH . 'backup/plugin-error.log',
                '[' . date('Y-m-d H:i:s') . "] MP_handleAjax: " . $e->getMessage() . "\n",
                FILE_APPEND | LOCK_EX
            );
            echo json_encode(['ok' => false, 'msg' => '服务器错误'], JSON_UNESCAPED_UNICODE);
            exit;
        }
    }
}

前端调用

后台页面(layout.php 已全局包装 fetch,同源请求自动加 X-CSRF-Token):

fetch('?action=my_plugin_ajax', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ item_id: 1, points: 10 })
})

前台页面(需要手动加 token):

var token = document.querySelector('meta[name="csrf-token"]').content;
fetch('<?= BLOG_URL ?>?action=my_plugin_ajax', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': token
  },
  body: JSON.stringify({ item_id: 1, points: 10 })
})

第 15 章:常见坑清单

15.1 安全

  1. 路径穿越——文件名过 basename()
  2. SQL 注入——占位符绑定
  3. XSS——输出过 h()
  4. IDOR——按 ID 操作必校验资源存在
  5. 上传——扩展名白名单 + 真实图片校验 + 随机文件名 + 像素上限
  6. 开放重定向——redirect($url) 只允许本站 URL
  7. CSRF——所有 POST 请求带 _token 或 X-CSRF-Token
  8. 权限——后台操作前 Auth::requireCap()
  9. ★ v3.2:相对 URL——所有表单 action 必须是绝对 URL

15.2 部署兼容

  1. 不硬编码路径——URL 拼 BLOG_URL,路径拼 CONTENT_PATH / DIR
  2. 子目录部署——假设站点可能在 https://example.com/blog/
  3. 大小写敏感——目录名小写下划线
  4. ★ v3.2:Cookie litelog_view=mobile——PC 也可能走移动模板

15.3 结构规范

  1. 一目录一插件
  2. 不留临时文件
  3. 不改核心文件(除必要的 dispatcher 补丁)
  4. option 加插件名前缀
  5. 生命周期文件不输出内容
  6. UTF-8 无 BOM,LF 行尾,php -l 校验
  7. ★ v3.2:PHP 标签合并——避免连续两个 <?php

15.4 输出缓冲陷阱

  1. 不要用 ob_start('callback')
  2. 回调必须判 is_string($html),非字符串返回 ''
  3. 长度守卫:处理后 < 原始 50% 时返回原始
  4. ob_start 回调内不能 ob_start
  5. 优先用 layout_* 锚点
  6. 优先用 Output::capture()

15.5 兼容性

  1. $DB 是全局
  2. $data 引用参数——log_save_before / comment_save_before 必须 function (&$data, ...)
  3. redirect() 已自带 exit
  4. $_SESSION['uid'] 是登录标识
  5. fetchOne() 失败返回 false
  6. insert() 返回字符串 lastInsertId——必须 (int)
  7. update() / delete() 返回 true
  8. Option::get() 第二参数永远 ''
  9. ★ v3.2:$_REQUEST 受 request_order 影响——显式用 $_POST / $_GET

15.6 标题相关(★ v3.2 新增)

  1. 主题 header 可能读 $page_title / $title / $__pageTitle
  2. 移动端 header 通常读 $__pageTitle
  3. 三变量都要设置
  4. 主题 header 硬编码时用 JS 覆盖 document.title

第 16 章:调试方法

16.1 遇到"行为不符预期"时的第一反应

不要猜。要读。

症状第一步
后台 500打开 display_errors,看报错行号
前台白屏F12 Console 看 JS;Network 看 HTML
路由不生效读 dispatcher.php
标题不对读主题 header.php 和 mobile/header.php
CSS/JS 没加载F12 Network 看状态码
CSRF 校验失败检查表单是否有 Csrf::field();AJAX 是否带 X-CSRF-Token
权限拒绝检查 Auth::can() / Auth::requireCap() 的能力名
删除/修改无效检查表单 action 是不是相对 URL
语法错误白屏php -l 检查每个文件

16.2 用 error_log 打点

@file_put_contents(
    CONTENT_PATH . 'backup/debug.log',
    '[' . date('Y-m-d H:i:s') . '] ' . var_export($someVar, true) . "\n",
    FILE_APPEND | LOCK_EX
);

16.3 var_dump 短路调试

var_dump($page_title);
exit;

16.4 CSRF 问题专项排查

症状排查
表单提交返回 {"ok":false,"msg":"缺少 CSRF 令牌"}表单是否加 Csrf::field()
AJAX 返回同上请求头是否有 X-CSRF-Token
页面显示 HTML 403请求类型不是 JSON
登录后操作正常,几小时后失败Session 过期,刷新页面重试

16.5 诊断脚本规范(★ v3.2 新增)

本轮修复战的关键工具。当用户无法提供错误信息、页面白屏无提示时,让用户放一个独立诊断脚本到根目录是最高效的定位方式。

16.5.1 完整诊断脚本模板

保存为 _diag_plugin.php,放网站根目录:

<?php
/**
 * 插件诊断脚本
 * 用法:放到网站根目录,浏览器访问 /_diag_plugin.php
 * 用完请删除!
 */

/* ★ 关键 1:关掉所有缓冲,避免诊断脚本自己白屏 */
while (ob_get_level() > 0) { @ob_end_clean(); }

/* ★ 关键 2:先定义 LITELOG_ROOT,否则 init.php 会 exit('Access Denied') */
if (!defined('LITELOG_ROOT')) {
    define('LITELOG_ROOT', __DIR__ . DIRECTORY_SEPARATOR);
}

/* ★ 关键 3:强制桌面视图,避免移动端 router 提前 exit */
$_GET['force_view'] = 'desktop';
$_COOKIE['litelog_view'] = 'desktop';

/* 打开错误显示 */
@ini_set('display_errors', '1');
@ini_set('display_startup_errors', '1');
@error_reporting(E_ALL);

header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>插件诊断</title>
<style>
body{font-family:-apple-system,Consolas,Menlo,monospace;font-size:13px;
     line-height:1.65;padding:22px;background:#0f172a;color:#e2e8f0;margin:0}
h1{color:#60a5fa;font-size:20px;margin:0 0 6px}
h2{color:#a78bfa;font-size:15px;margin:26px 0 10px;padding-bottom:6px;
   border-bottom:1px solid #334155}
.row{display:flex;gap:10px;align-items:baseline;margin:5px 0}
.tag{display:inline-block;padding:1px 9px;border-radius:5px;font-size:11.5px;
     font-weight:700;flex-shrink:0}
.tag-ok{background:#064e3b;color:#6ee7b7}
.tag-err{background:#7f1d1d;color:#fca5a5}
.tag-warn{background:#78350f;color:#fcd34d}
.tag-info{background:#1e3a8a;color:#93c5fd}
pre{background:#1e293b;padding:11px 14px;border-radius:7px;overflow-x:auto;
    white-space:pre-wrap;word-break:break-all;margin:6px 0;
    font-size:12.5px;border:1px solid #334155}
</style>
</head>
<body>
<h1>🔍 插件诊断报告</h1>
<p><?= date('Y-m-d H:i:s') ?> · PHP <?= PHP_VERSION ?></p>

<?php
function diag_row($tag, $text) {
    echo '<div class="row"><span class="tag tag-' . $tag . '">'
       . strtoupper($tag) . '</span><span>' . $text . '</span></div>';
}
function diag_h2($t) {
    echo '<h2>' . htmlspecialchars($t, ENT_QUOTES, 'UTF-8') . '</h2>';
}

/* ============ 阶段 1:加载核心 ============ */
diag_h2('阶段 1 · 加载 LiteLog 核心');

$initFile = LITELOG_ROOT . 'init.php';
if (!is_file($initFile)) {
    diag_row('err', 'init.php 不存在');
    echo '</body></html>'; exit;
}

try {
    require_once $initFile;
    diag_row('ok', 'init.php 加载完成');
} catch (Throwable $e) {
    diag_row('err', 'init.php 抛异常:' . htmlspecialchars($e->getMessage()));
    echo '<pre>' . htmlspecialchars($e->getFile() . ':' . $e->getLine()) . '</pre>';
    echo '</body></html>'; exit;
}

/* ============ 阶段 2:核心类与常量 ============ */
diag_h2('阶段 2 · 核心类与常量');

foreach (['Option','Hook','View','Csrf','Auth','Database','Dispatcher','PluginRegistry'] as $cls) {
    diag_row(class_exists($cls) ? 'ok' : 'warn', '类 ' . $cls);
}

foreach (['LITELOG_ROOT','BLOG_URL','CONTENT_PATH','DB_PREFIX','TEMPLATE_URL'] as $c) {
    if (defined($c)) {
        diag_row('ok', '常量 ' . $c . ' = ' . htmlspecialchars((string)constant($c)));
    } else {
        diag_row('err', '常量 ' . $c . ' 未定义');
    }
}

/* ============ 阶段 3:插件状态 ============ */
diag_h2('阶段 3 · 插件状态');

$pluginName = 'daily_article';  // ← 修改为你的插件名
$active = (string)Option::get('active_plugins', '');
if (strpos(',' . $active . ',', ',' . $pluginName . ',') !== false) {
    diag_row('ok', $pluginName . ' 已启用');
} else {
    diag_row('err', $pluginName . ' 未启用');
}

diag_row('info', 'url_rewrite = ' . Option::get('url_rewrite', 'n'));
diag_row('info', 'isMobileDevice() = ' . (function_exists('isMobileDevice') && isMobileDevice() ? 'YES' : 'NO'));

/* ============ 阶段 4:视图语法检查 ============ */
diag_h2('阶段 4 · 视图语法检查');

$base = CONTENT_PATH . 'plugins/' . $pluginName . '/';
foreach (['views/detail.php', 'views/index.php', 'controllers/DailyController.php'] as $rel) {
    $path = $base . $rel;
    if (!is_file($path)) {
        diag_row('err', $rel . ' 不存在');
        continue;
    }
    diag_row('ok', $rel . '(' . number_format(filesize($path)) . ' 字节)');

    if (function_exists('exec')) {
        $out = []; $ret = 0;
        @exec('php -l ' . escapeshellarg($path) . ' 2>&1', $out, $ret);
        if ($ret === 0) {
            diag_row('ok', '&nbsp;&nbsp;└ 语法通过');
        } elseif ($ret !== 127) {
            diag_row('err', '&nbsp;&nbsp;└ ' . implode(' ', $out));
        }
    }
}

/* ============ 阶段 5:路由识别 ============ */
diag_h2('阶段 5 · 路由识别');

$dispFile = LITELOG_ROOT . 'include/lib/dispatcher.php';
$dispContent = @file_get_contents($dispFile);
if (strpos($dispContent, 'matchPluginUri') !== false) {
    diag_row('ok', 'dispatcher.php 已含伪静态路由补丁');
} else {
    diag_row('warn', 'dispatcher.php 未含伪静态补丁');
}

/* ============ 阶段 6:错误日志 ============ */
diag_h2('阶段 6 · 最近错误日志');

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

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

16.5.2 关键要点

  1. while (ob_get_level() > 0) @ob_end_clean();——先清空所有缓冲
  2. define('LITELOG_ROOT', DIR . DIRECTORY_SEPARATOR)——必须先定义,否则 init.php 会 exit('Access Denied')
  3. 强制桌面视图——$_GET['force_view'] = 'desktop' 避免 mobile router 提前 exit
  4. 每步 try/catch——不让诊断脚本自己崩
  5. php -l 检查语法——用 exec 或 fallback

16.5.3 输出解读

  • 所有阶段 OK → 插件本身没问题,问题在别处
  • 阶段 4 出现 syntax error → 视图文件有 PHP 语法错误(本轮"每日一文"的问题)
  • 阶段 5 显示"未含伪静态补丁" → 需要应用 dispatcher 补丁
  • 阶段 6 有历史记录 → 可以从日志中找到具体报错

16.6 三点式日志探针法(★ v3.2 新增)

当诊断脚本也无法定位时(例如错误发生在渲染中且被吞掉),用三点式日志探针逐步确认执行到哪一步:

16.6.1 原理

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

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

不输出到页面是关键——避免探针本身触发新的错误。

16.6.2 三点布设

探针 ①:{插件名}.php 主文件,define('XXX_REGISTERED', true) 之后

if (defined('XXX_HOOKS_REGISTERED')) return;
define('XXX_HOOKS_REGISTERED', true);

@file_put_contents(
    CONTENT_PATH . 'backup/probe.log',
    '[' . date('Y-m-d H:i:s') . '] ① 主文件已加载 URI=' . ($_SERVER['REQUEST_URI'] ?? '') . "\n",
    FILE_APPEND | LOCK_EX
);

探针 ②:控制器方法开头(如 displayDetail)

public function displayDetail($id = 0) {
    @file_put_contents(
        CONTENT_PATH . 'backup/probe.log',
        '[' . date('Y-m-d H:i:s') . '] ② displayDetail 进入 id=' . (int)$id . "\n",
        FILE_APPEND | LOCK_EX
    );

    $GLOBALS['__da_active'] = true;
    // ...
}

探针 ③:视图文件顶部

<?php
@file_put_contents(
    CONTENT_PATH . 'backup/probe.log',
    '[' . date('Y-m-d H:i:s') . '] ③ detail.php 开始执行' . "\n",
    FILE_APPEND | LOCK_EX
);
?>

16.6.3 结果解读

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

升级:把探针增加到 4 个(在每个可能的"拐点"),逐步缩小范围。

16.6.4 清理

排错完成后务必删除所有探针代码和 probe.log。


第 17 章:完整最小骨架

17.1 目录结构

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

17.2 my_plugin.php

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

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

if (!defined('MP_VER')) define('MP_VER', '1.0.0');

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

if (!function_exists('MP_isEnabled')) {
    function MP_isEnabled() {
        return MP_conf('enabled', '1') === '1';
    }
}

/* ★ 通用 POST 按钮生成器(绝对 URL + CSRF) */
if (!function_exists('MP_postBtn')) {
    function MP_postBtn($action, array $params, $label, $confirm = '', $class = '') {
        $qs = http_build_query(array_merge(['action' => $action], $params));
        $formAction = BLOG_URL . '?' . $qs;

        $on = '';
        if ($confirm !== '') {
            $jsonConfirm = json_encode((string)$confirm,
                JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP);
            $on = ' onsubmit="return confirm(' . htmlspecialchars($jsonConfirm, ENT_QUOTES, 'UTF-8') . ')"';
        }
        $cls = $class !== '' ? ' class="' . htmlspecialchars($class, ENT_QUOTES, 'UTF-8') . '"' : '';

        return '<form method="post" action="' . htmlspecialchars($formAction, ENT_QUOTES, 'UTF-8') . '" style="display:inline"' . $on . '>'
             . (class_exists('Csrf') ? Csrf::field() : '')
             . '<button type="submit"' . $cls . ' style="background:none;border:0;padding:0;cursor:pointer;color:inherit;font:inherit">'
             . $label
             . '</button></form>';
    }
}

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

if (!function_exists('MP_injectCss')) {
    function MP_injectCss() {
        static $done = false;
        if ($done) return;
        $done = true;
        if (empty($GLOBALS['__mp_active'])) return;
        $ver = (int)(@filemtime(__DIR__ . '/assets/style.css') ?: 1);
        echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/assets/style.css?v=' . $ver . '">' . "\n";
    }
}

if (!function_exists('MP_injectJs')) {
    function MP_injectJs() {
        static $done = false;
        if ($done) return;
        $done = true;
        if (empty($GLOBALS['__mp_active'])) return;
        $ver = (int)(@filemtime(__DIR__ . '/assets/script.js') ?: 1);
        echo '<script src="' . BLOG_URL . 'content/plugins/my_plugin/assets/script.js?v=' . $ver . '"></script>' . "\n";
    }
}

/* 加载路由文件 */
if (class_exists('PluginRegistry')) {
    try {
        $__routes = __DIR__ . '/frontend/routes.php';
        if (is_file($__routes)) require_once $__routes;
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin require routes: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

17.3 my_plugin_callback.php

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

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

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

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

17.4 frontend/routes.php

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

try {
    PluginRegistry::addFrontendRoute('my_plugin/?', function ($matches) {
        if (!function_exists('MP_isEnabled') || !MP_isEnabled()) {
            header('HTTP/1.0 404 Not Found');
            if (class_exists('View')) View::display('404');
            return;
        }
        $GLOBALS['__mp_active'] = true;
        include __DIR__ . '/../views/main.php';
    });
} catch (Throwable $e) {
    @file_put_contents(
        CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] my_plugin route register: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX
    );
}

17.5 views/main.php(★ 三变量兜底)

<?php
/**
 * 我的插件 · 主页
 */
if (!defined('LITELOG_ROOT')) exit('error!');

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

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

/* ★ 终极方案:JS 强制覆盖 document.title */
if (empty($GLOBALS['__mp_title_hooked'])) {
    $GLOBALS['__mp_title_hooked'] = true;
    $__mp_title = $page_title;

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

include View::resolve('header');
?>

<div class="mp-container">
    <h1>我的插件</h1>

    <form method="post" action="<?= BLOG_URL ?>?action=my_plugin_ajax">
        <?= Csrf::field() ?>
        <input type="text" name="name" placeholder="名称">
        <button type="submit">提交</button>
    </form>
</div>

<?php include View::resolve('footer'); ?>

17.6 my_plugin_setting.php

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

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (class_exists('Csrf')) Csrf::verify();
    Option::set('my_plugin_title', trim($_POST['title'] ?? ''));
    redirect(BLOG_URL . 'admin/?action=plugin_setting&name=my_plugin&saved=1');
}
?>

<h2>我的插件设置</h2>

<?php if (!empty($_GET['saved'])): ?>
  <div class="card" style="background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46">
    设置已保存
  </div>
<?php endif; ?>

<form method="post" class="form">
  <?= Csrf::field() ?>
  <div class="field">
    <label>插件标题</label>
    <input type="text" name="title" value="<?= h(Option::get('my_plugin_title', '')) ?>">
  </div>
  <button class="btn" type="submit">保存</button>
</form>

17.7 assets/style.css / assets/script.js

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


第 18 章:交付前自检清单

结构

  • [ ] 目录名 = 主文件名(小写下划线)
  • [ ] 有 {插件名}.php + {插件名}_callback.php
  • [ ] 有 frontend/routes.php(如需要)
  • [ ] 有 views/ 目录
  • [ ] 有 assets/ 目录(哪怕空文件)
  • [ ] 有 {插件名}_setting.php(如需设置页)

安全

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

⭐ 500 免疫

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

⭐ 视图与资源

  • [ ] 前台视图 include View::resolve('header') 和 footer
  • [ ] 前台视图设置三个标题变量 $page_title / $title / $__pageTitle
  • [ ] 前台视图不自建 <!DOCTYPE html>
  • [ ] CSS/JS 走 layout_head_end / layout_footer_scripts Hook 注入
  • [ ] 资源 URL 拼 BLOG_URL,带 ?v=filemtime
  • [ ] ★ v3.2:PHP 标签合并,避免连续两个 <?php

⭐ 输出缓冲

  • [ ] 没有使用 ob_start('callback') 做页面拦截
  • [ ] 如果必须用输出缓冲,用 Output::capture()
  • [ ] 如果注册了 dispatch_end,逻辑里有长度守卫
  • [ ] 如果注册了 dispatch_end,逻辑里有非字符串输入处理

API 使用

  • [ ] $DB->fetchOne() 判 false
  • [ ] $DB->insert() 返回值 (int) 转换
  • [ ] 需要行数用 query()->rowCount()
  • [ ] 用户名用 nickname ?: username
  • [ ] URL 拼接不重复加斜杠
  • [ ] 函数/常量/表名有插件前缀
  • [ ] AJAX 端点九条铁律全过
  • [ ] ★ v3.2:$_REQUEST 换成显式 $_POST / $_GET

验证

  • [ ] 启用 → 访问 → 停用 → 再启用 → 卸载 全流程无异常
  • [ ] 浏览器标签页标题正确
  • [ ] F12 Console 无报错,Network 全 200
  • [ ] 卸载后表删除、option 清空
  • [ ] ★ v3.2:用 _diag_plugin.php 走查一遍

第 19 章:开发经验教训(30 条铁律)

v3.2 从 22 条扩到 30 条,加入本轮"每日一文"修复战的全部教训。

19.1 三十条铁律

#教训
1规范是地图,内核是地形。地图和地形冲突时,信地形
2不确定的机制,先用 error_log / var_dump / 读源码验证,禁止靠猜
3有可工作参考实例时,先"逐行结构对齐",再谈业务
4导出/文档没看到某物 ≠ 某物不存在;先问,再断言
5每条规范都有场景前提,问清上下文再套用
6少说"一定";多说"请验证" / "如未生效,查这里"
7"多键兜底""兼容写法"往往是懒惰的遮羞布
8视图不能自建 HTML 骨架
9CSS/JS 走 Hook 注入
10内核只自动加载主文件;子文件要主文件主动 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★ v3.2:所有表单 action 必须是绝对 URL——action="?xxx" 在伪静态路径下会被解析为当前路径 + query,路由错乱
24★ v3.2:JS 动态生成表单的 action 也必须是绝对 URL
25★ v3.2:视图顶部同时设置 $page_title / $title / $__pageTitle——主题 header 变量名不可预测
26★ v3.2:主题 header 硬编码时,用 JS 强制覆盖 document.title——这是终极可靠方案
27★ v3.2:连续两个 <?php 是陷阱——合并为一个大 PHP 块,只在输出 HTML 时闭合
28★ v3.2:诊断脚本必须先 define('LITELOG_ROOT', ...)——否则 init.php 会 exit('Access Denied')
29★ v3.2:移动端 router.php 会抢跑并 exit——init.php 里它在 loadActivePlugins() 之前执行
30★ v3.2:$_REQUEST 受 request_order 影响——显式用 $_POST / $_GET

19.2 常见错误推理链

❌ 错误链 A:路由不生效 → "可能 PluginRegistry 不存在" → 改用 Hook → 引入新问题

✅ 正确链:读 dispatcher.php → 发现路由在核心匹配 → 检查 PluginRegistry::matchFrontendRoute → 检查自己的注册代码 → 修复

❌ 错误链 B:CSRF 报错 → "可能 Csrf 类没加载" → 加 class_exists 判断 → 还是错

✅ 正确链:CSRF 报错 → 看报错是"缺少令牌"还是"不匹配" → "缺少"就检查表单/AJAX 是否带 token → "不匹配"就检查 session 是否丢了

❌ 错误链 C:删除按钮点了没反应 → "可能权限问题" → 加日志 → 还是没反应

✅ 正确链:查看 F12 Network → 表单提交到了哪个 URL → 如果是 /forum/thread/5?action=... 而不是 /?action=... → 相对 URL 陷阱

❌ 错误链 D:标题不对 → 改 $page_title → 无效 → 改 $title → 无效 → 改 $__pageTitle → 无效 → "放弃"

✅ 正确链:读主题 header.php → 看它读哪个变量 → 如果硬编码站点名 → 用 JS 覆盖 document.title(万能解)

❌ 错误链 E:白屏 → "可能数据库问题" → 检查 DB → 正常 → "可能缓存问题" → 清缓存 → 还白屏

✅ 正确链:白屏 → 用 _diag_plugin.php → 阶段 4 显示 syntax error on line 17 → 视图文件有语法错误 → php -l 验证 → 修复


第 20 章:给 AI 的指令模板

20.1 从零开发新插件

请按 LiteLog 插件开发规范 v3.2 开发一个 XXX 插件:

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

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

严格遵守本文件所有条款,特别是:
- 第 1 章「500 错误免疫」的 9 条口诀
- 第 2 章「五条硬性安全规则」,尤其 2.6「绝对 URL」
- 第 4 章「自动加载真相」(主文件必须 require 子文件)
- 第 5.3 节「PHP 语法安全写法」
- 第 7.6 节「伪静态路由识别」
- 第 8.5 节「相对 URL 陷阱」
- 第 10 章「视图必须 include 主题 header/footer」+「表单必须加 CSRF」+「三变量兜底」
- 第 11 章「CSS/JS 走 layout_* 注入」
- 第 14.4 节「AJAX 九条铁律」
- 第 18 章交付前自检清单

不要修改任何核心文件(除非明确需要 dispatcher 补丁)。完成后给出完整文件列表和每个文件的完整代码。

20.2 排查 500 错误

我的插件启用后后台 500。请根据规范第 1 章排查。

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

报错信息:……

20.3 排查 CSRF 错误

我的插件出现 CSRF 校验失败:……

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

当前代码:(贴表单 / AJAX / 端点)
日志:(content/backup/plugin-error.log)

请按第 2.4 节排查。

20.4 排查"按钮点了没反应"

我的插件里有一个删除按钮,点击后页面刷新了但目标没被删除。

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

当前表单代码:……
浏览器 Network 面板显示的请求 URL:……

请按第 8.5 节「相对 URL 陷阱」排查。

20.5 排查"标题不对"

我的插件页面浏览器标签始终显示站点名,不是插件标题。

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

主题 header.php 的 <title> 那行代码:……
插件视图顶部代码:……

请按第 10.7 节排查。

20.6 排错通用模板

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

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

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

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

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

收尾:一句话总结

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

v3.0 五条核心原则:

① 视图 include 主题 header/footer ② 资源走 layout_* Hook 注入 ③ 不要用 ob_start('callback') 拦截页面 ④ 必须用输出缓冲时,用 Output::capture() + 长度守卫 ⑤ 机制不明,先读源码

v3.1 三条新增:

⑥ 所有 POST 表单加 Csrf::field(),AJAX 带 X-CSRF-Token ⑦ 后台操作前调 Auth::requireCap() ⑧ 插件设置页放 {插件名}_setting.php

v3.2 八条新增(本轮修复战的全部精华):

⑨ 所有表单 action 必须是绝对 URL——相对 URL 在伪静态路径下会路由错乱 ⑩ 标题三变量兜底 + 必要时 JS 覆盖 document.title——主题 header 变量名不可预测 ⑪ PHP 标签合并,避免连续两个 <?php——防 Parse error ⑫ 诊断脚本必须先 define('LITELOG_ROOT')——否则被 init.php 拒绝 ⑬ 三点式日志探针法——不输出、只写文件,定位白屏崩在哪一步 ⑭ 伪静态路由识别——核心 dispatcher 补丁,让所有插件自动支持 /插件名/xxx ⑮ 移动端 router 会抢跑——init.php 里它在 loadActivePlugins() 之前 ⑯ $_REQUEST 换成显式 $_POST / $_GET——不受 request_order 影响

掌握这 8 + 5 + 3 + 5 条 + 三条硬性规则 + 九条红线,99% 的插件能一次跑通。


本文件基于 LiteLog 内核 v1.2 + default 主题 v1.3.1 + 每日一文 v1.2.7 完整修复战整理。

v3.2 相对 v3.1 的核心新增:

  • 相对 URL 陷阱(第 8.5 节)
  • 页面标题三变量兜底 + JS 强制覆盖(第 10.7 节)
  • 伪静态路由识别(第 7.6 节)
  • PHP 语法安全写法(第 5.3 节)
  • 诊断脚本规范(第 16.5 节)
  • 三点式日志探针法(第 16.6 节)
  • 移动端 router 抢跑(第 13.6 节)
  • 第 19 章从 22 条扩到 30 条
登录 或 后台登录 后参与讨论