主站
论坛首页/ LiteLog /帖子

LiteLog 插件开发规范(v1.2 · 完整版)

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

LiteLog 插件开发规范(v2.0 · 实测修订版)

版本:2.0(2026-10-11) 性质:基于 LiteLog v1.0.0 内核源码实测修订。修正 v1.2 中若干与内核不符的描述,新增「视图 / 资源注入 / 生命周期 / 调试方法」专章。 自包含性:任何 AI 拿到本文件后,无需再阅读核心代码即可完成插件开发。 适用范围:LiteLog 内核 content/plugins/ 目录下所有插件 核实基准:LiteLog v1.0.0 内核(2026-10-10 导出) 给 AI 下指令时,请把本文件完整附上。

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

在你写任何 LiteLog 插件代码之前,请按顺序读完以下章节:

  1. 第 1 章《500 免疫》 —— 违反任意一条,插件启用后整个后台会 500,用户连停用都做不到。
  2. 第 2 章《三条硬性安全规则》 —— 安全底线,违反即不合格。
  3. 第 3 章《八条最高级别红线》 —— 一票否决项。
  4. 第 4 章《目录结构与自动加载真相》 —— ★ v2.0 新增:明确哪些文件被自动加载、哪些不是。
  5. 第 10 章《视图与模板》 —— ★ v2.0 重写:前台视图必须 include 主题 header/footer,绝对不能自建 HTML 骨架。
  6. 第 11 章《资源加载》 —— ★ v2.0 重写:CSS/JS 通过 Hook 注入,不是视图里直接 link。

之后,用第 5-9 章查「主文件 / 生命周期 / 注册表 / 钩子 / 数据库」的具体写法;用第 14 章《API 参考》查符号、签名、字段;用第 17 章《完整最小骨架》直接抄写模板。

最后,用第 16 章《调试方法》排错,用第 18 章《交付前自检清单》逐条打钩,全部通过再交付。

⚠️ v2.0 核心警告:本规范中标注「★ 实测」的条目,是基于内核源码逐行核对得出的结论,与 v1.2 中基于传闻的描述可能不同。当 v2.0 与 v1.2 冲突时,以 v2.0 为准。


目录


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

为什么放第一:一旦发生,整个后台 500,用户连「停用插件」都点不进去,只能进数据库或文件系统手动处理。违反本章任意一条,插件即不合格。

1.1 症状描述

  • 插件启用后,访问任何后台页面都 500(包括「插件列表」页本身)。
  • 只能通过数据库改 option 表或删除 content/plugins/{插件名}/ 目录来恢复。

根因:内核 loadActivePlugins() 在每次前台/后台请求都被调用;插件主文件里任意 fatal error 会击垮整个站点。

1.2 七宗罪

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

为什么炸:主文件可能被 include 多次。第二次执行无守卫的 define / function 会:

  • define() → Constant already defined(PHP 8 起可能升级为 Error)
  • function xxx() → Cannot redeclare xxx()(致命错误,立即 500)

错误:

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
define('ICG_VER', '1.0.0');              // ← 重复加载即报错
function ICG_isEnabled() { /* ... */ }   // ← 重复加载直接 fatal

正确:

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

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

内核 Option::get($name, $default = '') 严格类型。传 null 在 PHP 8 会触发警告,某些扩展路径会 TypeError。

正确:

$v = Option::get('icon_generator_enabled', '');   // ★ 永远用空串
if ($v === '') { /* 未配置 */ }
口诀:「Option::get 第二个参数永远写 ''。」

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

为什么炸:不同 MySQL 版本对 COMMENT / COLLATE / 特殊 ENGINE 支持有差异。DDL 抛异常时若 callback_init() 无 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
        );
    }
}

关键三条:

  1. 整个 callback_init() 用 try/catch 包住
  2. DDL 去掉 COMMENT(列注释零损失)
  3. COLLATE 也可省(CHARSET=utf8mb4 已够)

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

为什么炸:视图文件是独立 include 的。若主文件因故未加载(前置 fatal、路径错误、被内核跳过),视图里直接调 ICG_isEnabled() 就是 Call to undefined function → fatal。

错误:

// views/generator.php
<?php if (!defined('LITELOG_ROOT')) exit('error!'); ?>
<h1><?= ICG_isEnabled() ? '已启用' : '未启用' ?></h1>

正确:

// views/generator.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!function_exists('ICG_isEnabled')) {
    echo '<div class="error">插件未正确加载</div>';
    return;
}
?>
<h1><?= ICG_isEnabled() ? '已启用' : '未启用' ?></h1>
口诀:「视图先 function_exists 守卫。」

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

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

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

PluginRegistry 只在后台请求时被内核加载(admin/index.php 里 require)。若插件主文件在前台被加载时调用了 PluginRegistry,class_exists('PluginRegistry') 会返回 false——此时不做守卫直接调用会 fatal。

正确:

if (class_exists('PluginRegistry')) {
    try {
        PluginRegistry::addFrontendRoute('icon/?', function ($m) { ... });
    } catch (Throwable $e) { /* 写日志 */ }
}

罪 7 ★★★:路由注册文件(frontend/routes.php)被主文件忘记 require

★ v2.0 新增:内核 不会自动加载 frontend/routes.php。如果主文件里没写 require_once DIR . '/frontend/routes.php';,路由永远不会注册。

正确:

// 在 icon_generator.php 主文件末尾
if (class_exists('PluginRegistry')) {
    try {
        $__routes = __DIR__ . '/frontend/routes.php';
        if (is_file($__routes)) require_once $__routes;
    } catch (Throwable $e) { /* 写日志 */ }
}

1.3 七条记忆口诀

**「define 判 defined, function 判 exists, Option 用空串, callback 整段包, DDL 去 COMMENT, 视图先守卫, Hook 也包 try。」**

1.4 500 排查标准流程

第 1 步 — 先停用插件(恢复后台)

  • 文件方式:把 content/plugins/{插件名}/ 改名(加 .bak 后缀),后台即可恢复
  • 数据库方式:UPDATE {前缀}option SET option_value = REPLACE(option_value, '{插件名},', '') WHERE option_name = 'active_plugins';

第 2 步 — 临时打开错误显示

在 init.php 最前面临时加:

ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);

第 3 步 — 查三处日志

  1. PHP 错误日志:php_errors.log / nginx error log / php-fpm error log
  2. 插件日志:content/backup/plugin-error.log
  3. Web 服务器日志

第 4 步 — 对照报错关键词

报错关键词原因对应条款
Cannot redeclare xxx()主文件顶层 function 无守卫罪 1
Constant already defined主文件顶层 define 无守卫罪 1
Call to undefined function ICG_xxx()主文件未加载 / 视图守卫缺失罪 4
Call to undefined class PluginRegistry在前台路径调用了 PluginRegistry罪 6
SQLSTATE[42000]: ... near 'COMMENT ...'DDL 用了不兼容语法罪 3
syntax error, unexpected 'xxx'语法错误 / BOM 头—

第 2 章:三条硬性安全规则

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

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

铁律:凡是文件名,一律先过 basename(),不允许直接拼用户输入。

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

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

// ✅ 批量 IN 必须 intval + 占位符
$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 '<a href="' . h($url) . '">' . h($name) . '</a>';

// ❌
echo '<h1>' . $log['title'] . '</h1>';

铁律:凡输出到页面的字符串,绝不能绕过 h()。富文本需展示 HTML 时,必须先经过白名单过滤或明确信任来源(管理员输入)。


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

红线 1 — 严禁反序列化与动态执行

禁止 unserialize()、eval()、assert()、create_function()、preg_replace('/e')、call_user_func($_GET[...])。数据序列化统一用 json_encode() / json_decode($s, true)。

红线 2 — 严禁滥用绝对路径

所有路径必须基于 LITELOG_ROOT、CONTENT_PATH、ADMIN_PATH、INCLUDE_PATH 常量,或 DIR 拼接。禁止 $_SERVER['DOCUMENT_ROOT'] 直接拼路径。

红线 3 — 渲染钩子内禁止中断页面

所有渲染类钩子(template_head、body_end、admin_head、admin_footer、log_item_meta 等)内绝不能使用 die() / exit() / header()。必须 return;。

红线 4 — 上传文件必须白名单 + 随机文件名

$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg','png','gif'], true)) { return; }
$filename = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;

上传前必须校验真实性:图片用 getimagesize(),其它用 MIME 白名单。

红线 5 — 数量扣减必须原子更新

$stmt = $DB->query(
    "UPDATE `" . DB_PREFIX . "goods` SET `stock`=`stock`-1 WHERE `id`=? AND `stock`>0",
    [(int)$id]
);
if ($stmt->rowCount() === 0) {
    return ['ok' => false, 'msg' => '库存不足'];
}

红线 6 — IN (...) 必须强转 + 占位符

$ids = array_map('intval', $ids);
$in  = implode(',', array_fill(0, count($ids), '?'));
$DB->fetchAll("SELECT * FROM t WHERE id IN ($in)", $ids);

红线 7 — PDO 属性不得覆盖

内核 Database 已统一设置 PDO::ATTR_ERRMODE = ERRMODE_EXCEPTION + PDO::ATTR_EMULATE_PREPARES = false。插件不得调用 setAttribute() 覆盖这两个属性。

红线 8 — AJAX 写操作必须有登录校验

if (empty($_SESSION['uid'])) {
    echo json_encode(['ok' => false, 'msg' => '未登录']);
    exit;
}

涉及管理员操作的,还要校验 $_SESSION['role'] === 'admin'。


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

4.1 目录结构

content/plugins/{插件名}/
├── {插件名}.php                    ← 【必须】主文件 + 插件头注释
├── {插件名}_callback.php           ← 【可选】生命周期回调(建表 / 清理)
├── admin/
│   ├── register.php                ← 【可选】后台菜单/页面注册(需主文件主动 require)
│   ├── pages/                      ← 后台视图(PHP 文件)
│   └── assets/                     ← 后台专属 CSS/JS
├── frontend/
│   └── routes.php                  ← 【可选】前台路由注册(需主文件主动 require)
├── views/                          ← 【可选】前台视图(前后台共用)
├── assets/                         ← 【可选】插件静态资源(Web 直接可访问)
└── data/                           ← 【可选】插件独立数据目录(建议加 .htaccess)

4.2 ★ 内核真实自动加载行为

内核 loadActivePlugins() 只做这一件事:

// include/lib/function.common.php 实测源码
function loadActivePlugins() {
    $active = Option::get('active_plugins', '');
    if (!$active) return;
    foreach (explode(',', $active) as $p) {
        $p = trim($p);
        if (!$p) continue;
        $file = CONTENT_PATH . 'plugins/' . $p . '/' . $p . '.php';
        if (file_exists($file)) require_once $file;   // ← 只加载主文件
    }
}

结论:

文件内核是否自动加载你需要做什么
{插件名}.php✅ 是无
{插件名}_callback.php❌ 否内核在启用/停用时主动 require(见第 6 章)
frontend/routes.php❌ 否主文件里必须 require_once DIR . '/frontend/routes.php';
admin/register.php❌ 否主文件里必须 require_once DIR . '/admin/register.php';
views/*.php❌ 否由路由回调 / 控制器主动 include
assets/*❌ 否通过 Hook 或视图 <link> 引入

★ v2.0 核心修正:v1.2 中说"admin/register.php 和 frontend/routes.php 每次请求自动加载"——这是错误的。内核只加载主文件。这两个文件必须在主文件顶层主动 require。

4.3 主文件里正确的加载顺序

// icon_generator.php 主文件末尾
require_once __DIR__ . '/frontend/routes.php';   // 注册前台路由
// require_once __DIR__ . '/admin/register.php'; // 注册后台菜单(如果用)

// 生命周期回调不用主文件 require,内核在启用时自己 require

顺序建议:常量 → 函数 → Hook 注册 → require 子文件。

4.4 禁止

  • 在插件目录外(核心 admin/、include/ 目录)写任何文件。
  • 修改核心文件——所有扩展点都必须走 PluginRegistry + Hook。
  • 遗留 .bak / .tmp / .old / .revert 等临时文件。

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

主文件 content/plugins/{插件名}/{插件名}.php 是插件的唯一自动加载入口。

5.1 标准头注释

<?php
/**
 * Plugin Name: 友情链接
 * Description: 管理友情链接,支持排序与前台展示
 * Version: 1.0.0
 * Author: 你的名字
 * Author URI: https://example.com
 */
if (!defined('LITELOG_ROOT')) exit('error!');

头注释支持的字段(admin/helpers.php::parseHeader() 实测):

字段说明备选写法
Plugin Name显示名Name
Description功能描述—
Version版本号—
Author作者—
Author URI作者主页Author URL

5.2 主文件的构成

主文件应该只做四件事:

  1. 常量守卫(define + function)
  2. 定义工具函数(配置读取、辅助函数)
  3. 注册 Hook(Hook::addAction)
  4. require 子文件(frontend/routes.php / admin/register.php)

禁止:

  • 主文件顶层输出任何内容(不 echo、不 print、不 header)
  • 主文件顶层执行 DB 写入(应在 callback_init() 里做)
  • 主文件顶层做耗时的 IO

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

★ v2.0 完全重写:内核不使用 activate.php / deactivate.php / uninstall.php。真实的生命周期文件是 {插件名}_callback.php。

6.1 内核实测行为

admin/index.php::plugin_toggle 分支的逻辑(实测):

case 'plugin_toggle':
    $name = $_GET['name'] ?? '';
    $callbackFile = CONTENT_PATH . 'plugins/' . $name . '/' . $name . '_callback.php';

    if (in_array($name, $activeList, true)) {
        // ---- 停用 ----
        saveActivePlugins(array_diff($activeList, [$name]));
        if (is_file($callbackFile)) {
            require_once $callbackFile;
            if (function_exists('callback_rm')) @callback_rm();
        }
        Hook::doAction('plugin_deactivate', $name);
    } else {
        // ---- 启用 ----
        saveActivePlugins([...$activeList, $name]);
        if (is_file($callbackFile)) {
            require_once $callbackFile;
            if (function_exists('callback_init')) @callback_init();
        }
        Hook::doAction('plugin_activate', $name);
    }

结论:

时机内核调用你的回调文件
启用插件callback_init(){插件名}_callback.php
停用插件callback_rm()同上
升级插件callback_up()(内核当前未主动调用,可自己手动触发)同上

6.2 {插件名}_callback.php 模板

<?php
/**
 * 图标生成器 · 生命周期回调
 */
if (!defined('LITELOG_ROOT')) exit('error!');

/**
 * 启用插件时调用
 * - 建表、写初始 option
 * - 必须整段 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");

        if (class_exists('Option')) {
            foreach ([
                'enabled' => '1',
                'title'   => '我的插件',
            ] as $key => $val) {
                $optKey = 'my_plugin_' . $key;
                if (Option::get($optKey, '') === '') {
                    Option::set($optKey, $val);
                }
            }
        }
    } 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() {
    // 空操作即可。删表是用户主动卸载时才做的事。
}

/**
 * 卸载插件时调用(可选,需要用户手动触发)
 * 内核目前没有"卸载"入口,一般由管理员手动调用 callback_uninstall() 或直接删目录
 */
function callback_uninstall() {
    global $DB;
    try {
        $DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "my_items`");
    } catch (Throwable $e) {}
    try {
        $DB->delete('option', "option_name LIKE 'my_plugin_%'");
    } catch (Throwable $e) {}
}

6.3 约定

  • callback_init() 必须整段 try/catch(罪 3)
  • DDL 不加 COMMENT / COLLATE(罪 3)
  • callback_rm() 不删数据(停用是"临时关闭",删表是卸载的职责)
  • callback_uninstall() 先删表 → 再清 option
  • plugins_cache.json 由系统自动重建,不要手动删

第 7 章:★ PluginRegistry 注册表

★ v2.0 修正:PluginRegistry 真实存在,位于 include/lib/plugin_registry.php。它在后台请求时由内核加载。

7.1 完整 API(实测)

方法签名用途
addAdminPageaddAdminPage(string $action, array $config)注册后台页面
hasAdminPagehasAdminPage(string $action): bool是否存在该页面
getAdminPagegetAdminPage(string $action): ?array获取页面配置
addAdminMenuaddAdminMenu(array $menu)注册侧边栏菜单
getAdminMenusgetAdminMenus(): array获取所有菜单
addFrontendRouteaddFrontendRoute(string $regex, callable $callback)注册前台路由
matchFrontendRoutematchFrontendRoute(string $uri): bool匹配前台路由
addAdminAssetaddAdminAsset(string $action, string $type, string $url)注册后台资源
getAdminAssetsgetAdminAssets(string $action, string $type): array获取后台资源
renderAdminPagerenderAdminPage(string $action)渲染后台页面

7.2 前台路由注册

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

try {
    PluginRegistry::addFrontendRoute('icon/?', function ($matches) {
        /* 主文件未加载 / 插件被禁用 → 404 */
        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');   // 自带 exit
            return;
        }

        /* 标记正在渲染本插件页面,供 Hook 判断 */
        $GLOBALS['__icg_active'] = true;

        /* 只 include 视图,不在这里输出 HTML */
        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
    );
}
  • $regex 是不带前导斜杠的正则
  • 捕获组通过 $matches 传入,同时灌进 $_GET
  • 匹配条件:内置伪静态未命中,且 $_GET['action'] 未设置

7.3 后台菜单注册

// 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 () {
            include __DIR__ . '/pages/list.php';
        },
    ]);
} catch (Throwable $e) {
    @file_put_contents(
        CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] friend_links admin register: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX
    );
}

icon 支持的名字:

dashboard, file, comment, folder, settings, external, logout, plus,
edit, trash, plugin, palette, check, x, refresh

7.4 加载时机

admin/register.php 不会被内核自动加载。主文件里必须:

// 只在后台请求时加载
if (class_exists('PluginRegistry') && is_file(__DIR__ . '/admin/register.php')) {
    require_once __DIR__ . '/admin/register.php';
}

PluginRegistry 类本身也只在后台加载,所以 class_exists('PluginRegistry') 是最好的守卫。


第 8 章:★ 钩子系统全览(内核实测)

8.1 Hook API

include/lib/hook.php 提供 5 个方法:

方法说明
Hook::addAction($hook, $func, $priority = 10)注册回调
Hook::doAction($hook, ...$args)触发钩子
Hook::removeAction($hook, $func = null)移除回调
Hook::hasAction($hook)是否已挂载
Hook::captureAction($hook, ...$args)捕获输出并返回字符串

优先级:数字越小越先执行,默认 10。

重要限制:LiteLog 的 Hook 只有 action(副作用)语义,没有 filter(改值)语义。需改数据时通过引用传参:

Hook::addAction('log_save_before', function (&$data, $gid) {
    $data['title'] = '[置顶] ' . $data['title'];
});

8.2 ★ 钩子清单(基于内核源码实测)

生命周期

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

后台

钩子名触发点参数
admin_requestadmin/index.php 登录校验后、路由分发前$action
admin_head<head> 内无
admin_footer</body> 前无
admin_menu侧边栏「扩展」区$activeKey
admin_dashboard_widgets概览页末尾无
admin_log_edit_sidebar文章编辑页右栏$log
admin_log_save_before文章保存前(后台)&$data, $gid
admin_log_save_after文章保存后(后台)$gid, $data
user_login登录成功后$user
user_logout登出前$uid
theme_activate主题切换后$new, $old
option_save保存单键后$key, $value

★ 前台模板(v2.0 修正)

钩子名触发点参数
template_head主题 <head> 内(所有前台页面)无
body_end主题 </body> 前(所有前台页面)无
index_head列表页 head 区无

★ 关键修正:v1.2 中说的 index_head 只对列表页触发。全站范围注入 CSS/JS 应使用 template_head 和 body_end。

前台分发

钩子名触发点参数
dispatch_start分发开始(早于路由匹配)无
dispatch_end分发结束无
dispatch_matched路由匹配后&$matched
dispatch_plugin_route老式插件路由前$pluginName, $rest
public_action_xxx?action=xxx 分发前无

内容展示

钩子名触发点参数
log_list_query列表查询前&$cond, $page, $perPage
log_list_result列表查询后&$logs, $page, $total
log_list_before / log_list_head / log_list_after列表各阶段无
log_item_before / log_item_meta / log_item_body_after / log_item_after每篇文章$log
log_display_before / log_display_after详情渲染前后$log
log_content正文输出$log
record_display / sort_display / tag_display归档/分类/标签页—
log_save_before / log_save_after / log_delete_before / log_delete_after文章生命周期&$data / $gid

评论

钩子名触发点参数
comment_save_before提交前&$data
comment_save_after插入后$cid, $data
comment_post提交完成$data
public_action_comment_post?action=comment_post无

扩展点

钩子名说明
register_widgets注册自定义小工具
register_homepage_modes注册首页模式
permalink_build_log_url自定义文章 URL(写 $GLOBALS['__permalink_url_log'])
permalink_build_sort_url自定义分类 URL(写 $GLOBALS['__permalink_url_sort'])

8.3 钩子注册规范

  • 所有 Hook::addAction(...) 必须包在 class_exists('Hook') + try/catch(罪 5)
  • 钩子命名:核心钩子名保持原样;插件自己触发供其它插件监听的,必须加插件名前缀(如 friend_links_before_save)

第 9 章:★ 数据库与模型

9.1 ★ Database 类精确签名(实测修正)

方法参数 2 语义返回值
query($sql, $params = [])有 $params → prepare;无 → prepare 空数组PDOStatement(可 ->rowCount())
fetchAll($sql, $params = [])同上array(空时 [])
fetchOne($sql, $params = [])同上array \| false ⚠️
fetchColumn($sql, $params = [])同上标量 \
insert($table, $data)—lastInsertId 字符串 ⚠️(需 (int))
update($table, $data, $where, $whereParams = [])$where 不带 WHERE 关键字true ⚠️(不是受影响行数)
delete($table, $where, $params = [])同上true ⚠️

★★ v2.0 重大修正:

  1. fetchOne() 失败返回 false(不是 null)——与 v1.2 相反。判断要用 if ($row === false) 或 if (!$row)。
  2. insert() 返回字符串 lastInsertId——需 (int)$newId 转换。
  3. update() / delete() 返回 true(不是行数)——无法通过返回值判断"是否真改了"。要判断是否命中,必须先 SELECT 或改用 query()->rowCount()。

9.1.1 原子扣减要用 query()->rowCount()

因为 update() 返回 true,没法拿到行数。所以红线 5(原子扣减)的标准写法:

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

9.1.2 fetchOne() 的判空

$row = $DB->fetchOne("SELECT * FROM ... WHERE gid=?", [(int)$gid]);
if ($row === false) { /* 不存在 */ }   // 或 if (!$row)

9.1.3 insert() 返回值

$newId = (int)$DB->insert('my_items', [...]);   // ← 一定要 (int)

9.2 核心表清单(★ v2.0 修正)

{DB_PREFIX}user(用户主表)

列类型说明
uidINT PK注意是 uid 不是 id
usernameVARCHAR登录名
nicknameVARCHAR昵称(显示优先 nickname ?: username)
emailVARCHAR邮箱
passwordVARCHAR密码 hash
roleVARCHAR'admin' 为管理员

{DB_PREFIX}blog(文章表)

列类型说明
gidINT PK注意是 gid 不是 id
titleVARCHAR标题
contentLONGTEXT正文
content_mdLONGTEXTMarkdown 源
aliasVARCHARURL 别名
sortidINT分类 ID(不是 sort_id)
authorINT作者 UID
dateINT UNSIGNED发布时间戳
viewsINT UNSIGNED浏览量
comnumINT评论数
hideVARCHAR(1)'y'=隐藏,'n'=公开 ⚠️(不是 1/0)
typeVARCHAR'blog' / 'page'
topINT置顶
coverVARCHAR封面图 URL
excerptTEXT摘要

{DB_PREFIX}comment(评论表)

列类型说明
cidINT PK评论 ID
gidINT文章 ID
pidINT父评论 ID(0 = 顶级)
posterVARCHAR昵称
emailVARCHAR邮箱
urlVARCHAR网址
commentTEXT内容
ipVARCHARIP
agentVARCHARUA
dateINT UNSIGNED时间戳
hideVARCHAR(1)'y'=待审,'n'=通过

{DB_PREFIX}sort(分类表)

列类型说明
sidINT PK不是 id
sortnameVARCHAR分类名
aliasVARCHARURL 别名
pidINT父分类
descriptionTEXT描述

{DB_PREFIX}tag / {DB_PREFIX}tag_blog

★ v2.0 修正:关联表是 tag_blog(不是 tag_relation)。

表主键关键列
tagtidtagname
tag_blog—gid / tid

{DB_PREFIX}option(配置表)

列类型说明
idINT PK—
option_nameVARCHAR UNIQUE键名
option_valueTEXT值(永远是字符串)
autoloadVARCHAR(1)'y' / 'n'

★ 实测常用 option 键名:

键名值说明
site_title字符串站点名(不是 site_name)
site_description字符串站点描述
site_key字符串SEO 关键词
current_template字符串当前主题目录名
url_rewrite'y' / 'n'伪静态开关
index_lognum数字字符串首页文章数
comment_need_check'y' / 'n'评论审核
active_pluginsCSV 字符串已启用插件名列表

★ Option::get() 返回永远是 string(不是 int / bool / array)。判断用 === '1' / === 'y'。

9.3 $DB 使用示例

global $DB;

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

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

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

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

// 5) 更新(无行数返回)
$DB->update('my_items', ['name' => 'new'], 'id = ?', [(int)$id]);

// 6) 删除(无行数返回)
$DB->delete('my_items', 'id = ?', [(int)$id]);

// 7) 要判断"是否命中",用 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) { /* 未命中 */ }

// 8) 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);
}

// 9) 事务
$pdo = $DB->pdo();
$pdo->beginTransaction();
try {
    $DB->update('user_profile', ['credit' => 100], 'uid = ?', [(int)$uid]);
    $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()
SortModelall() / getOne() / getByAlias() / add() / update() / delete() / countInSort()
TagModelall() / getByName() / getByGid() / getGidsByTag() / attach() / detach()
CommentModelgetOne() / byLog() / byLogPaginated() / buildTree() / latest() / add() / delete() / countByLog() / refreshComnum() / hasRecentDuplicate()
UserModelgetOne() / getByUsername() / login() / update()
LinkModelall()

优先使用模型方法。

9.5 Option 配置存储

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

// 复杂结构 → JSON
Option::set('my_plugin_config', json_encode(['a' => 1], JSON_UNESCAPED_UNICODE));
$cfg = json_decode(Option::get('my_plugin_config', '{}'), true);

键名前缀强制 {插件名}_。


第 10 章:★ 视图与模板

★ v2.0 完全重写:v1.2 只说了"后台视图不要 include layout",但没说前台视图该怎么做。这一章补齐。

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

这是 v2.0 最重要的修正。

错误做法(导致标题显示为站点名的元凶):

<!-- ❌ views/generator.php -->
<!DOCTYPE html>
<html>
<head>
    <title>图标生成器 - 站点名</title>   <!-- ← 主题 header.php 已输出过 <title>,这个被浏览器忽略 -->
</head>
<body>
    ... 内容 ...
</body>
</html>

正确做法:

<!-- ✅ views/generator.php -->
<?php
if (!defined('LITELOG_ROOT')) exit('error!');

// 设置页面标题(由主题 header.php 输出到 <title>)
$page_title = '图标生成器 - ' . Option::get('site_title', '');

// include 主题 header(输出 <!DOCTYPE html><html><head>...</head><body>...)
include View::resolve('header');
?>

<!-- 只输出 body 内部内容 -->
<div class="my-container">
    ... 页面内容 ...
</div>

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

原理:

  • View::resolve('header') 会解析到当前主题的 header.php
  • 主题的 header.php 已经输出了完整的 <!DOCTYPE html><html><head><title>...</title>...
  • 我们只需设 $page_title 变量,主题会读取它渲染 <title>
  • 视图只需输出 <body> 内部的内容

10.2 视图位置

类型位置
前台视图content/plugins/{插件名}/views/*.php
后台视图content/plugins/{插件名}/admin/pages/*.php

10.3 视图顶部守卫模板

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

/* ★ 前置守卫:主文件未加载则优雅退场 */
if (!function_exists('ICG_isEnabled')) {
    echo '<div class="error">插件未正确加载,请检查插件目录与激活状态。</div>';
    return;
}

/* ★ 站点名探测(可选,用于标题) */
$__site = Option::get('site_title', '');
$page_title = '图标生成器' . ($__site !== '' ? ' - ' . $__site : '');

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

10.4 后台视图的特殊约定

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

<?php
// admin/pages/settings.php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!function_exists('ICG_isEnabled')) { echo '插件未加载'; return; }
?>
<h2>设置</h2>
<div class="card">
    ... 后台页面内容 ...
</div>

10.5 输出转义

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

10.6 后台可用的核心 CSS 类

类名用途
.card / .card-title卡片
.btn / .btn-outline / .btn-danger / .btn-sm按钮
.table-wrap / .table表格
.form / .field表单
.tag / .tag-published / .tag-hidden标签
.empty / .error / .pagination其它

10.7 常用辅助函数

函数说明
h($s)HTML 转义
redirect($url)302 跳转 + exit(已自带 exit)
isLoggedIn()是否登录
checkLogin()强制登录
dateFmt($ts) / datetimeFmt($ts)日期格式化
stripTags($s, $len) / getExcerpt($html, $len)文本处理
makeAlias($str)URL 别名
pagination($page, $total, $perPage)分页 HTML
getPageNumber() / pageUrl($page)分页参数

10.8 URL 生成

Url::log($gid, $alias);            // 文章链接
Url::sort($sid, $alias);           // 分类链接
Url::tag($name);                   // 标签链接
Url::author($uid);                 // 作者链接
Url::record('2026-10');            // 归档链接

禁止硬编码 '/post/1.html'、'/admin/'。


第 11 章:★ 资源加载

★ v2.0 完全重写:v1.2 建议"视图内直接输出 <link> / <script>",这在前台视图里会导致资源出现在 <body> 中间。正确做法是走 Hook 注入。

11.1 目录

  • 插件静态资源:content/plugins/{插件名}/assets/
  • 后台专属资源:content/plugins/{插件名}/admin/assets/

11.2 前台资源:通过 Hook 注入

// 主文件 icon_generator.php

if (class_exists('Hook')) {
    try {
        /* 注入 CSS 到主题 <head> */
        Hook::addAction('template_head', function () {
            if (empty($GLOBALS['__icg_active'])) return;  // 只在插件页面注入
            $ver = (int)(@filemtime(__DIR__ . '/assets/style.css') ?: 1);
            echo '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">' . "\n";
            echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/icon_generator/assets/style.css?v=' . $ver . '">' . "\n";
        });

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

视图里只需要:

// frontend/routes.php 回调里
$GLOBALS['__icg_active'] = true;   // 告诉 Hook 我们正在渲染本插件页面
include __DIR__ . '/../views/generator.php';

11.3 后台资源

Hook::addAction('admin_head', function () {
    if (empty($GLOBALS['__icg_admin'])) return;
    echo '<link rel="stylesheet" href="' . BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.css">' . "\n";
});

Hook::addAction('admin_footer', function () {
    if (empty($GLOBALS['__icg_admin'])) return;
    echo '<script src="' . BLOG_URL . 'content/plugins/my_plugin/admin/assets/admin.js"></script>' . "\n";
});

11.4 版本号防缓存

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

11.5 零内联原则

  • CSS 收敛到 assets/.css,JS 收敛到 assets/.js
  • 避免在内联 <script> 里塞大段逻辑
  • ★ v2.0 提示:如果视图不能输出 <head>(因为它被 include 到主题骨架里),那 CSS/JS 必须走 Hook

11.6 数据目录保护

content/plugins/{插件名}/data/ 必须放 .htaccess 禁止 Web 访问:

<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($name, $value, $label, $ph)文本框
fieldNumber($name, $value, $label, $min, $max)数字框
fieldSelect($name, $value, $options, $label)下拉框
output($instanceId, $title, $content)标准外壳

表单字段必须带 data-setting="键名" 属性。

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 执行
  ├─ 定义常量:LITELOG_ROOT / CONTENT_PATH / ADMIN_PATH / INCLUDE_PATH / BLOG_URL / DB_PREFIX
  ├─ 加载核心 lib:Database($DB) / Option / Hook / 模型类 / helper 函数
  ├─ 建立 PDO 连接
  └─ loadActivePlugins()
       └─ 只 require 每个启用插件的 {插件名}.php
  ↓
Dispatcher::dispatch()
  ├─ Hook::doAction('dispatch_start')
  ├─ 检查 $_GET['__plugin_path'] → dispatch_plugin_route
  ├─ 标准 Rewrite URL 解析
  ├─ PluginRegistry::matchFrontendRoute() → 匹配到则执行回调
  ├─ Hook::doAction('public_action_{action}')
  └─ 渲染

★ v2.0 提示:PluginRegistry::matchFrontendRoute() 在内核 Dispatcher 里被主动调用(include/lib/dispatcher.php)。所以只要 PluginRegistry::addFrontendRoute 注册成功,路由就会自动匹配。

13.2 各文件已可用符号表

文件触发时机已可用符号
主文件init 后、路由前常量、$DB、Option、Hook、模型类、h()、redirect()、isLoggedIn()、$_SESSION['uid']
frontend/routes.php主文件 require 时上述全部 + PluginRegistry(仅后台请求时可用)
admin/register.php后台请求时(主文件 require)上述全部 + PluginRegistry、adminIcon()
{插件名}_callback.php后台启用/停用时LITELOG_ROOT、CONTENT_PATH、DB_PREFIX、$DB、Option
前台视图由路由回调 include全局全部可用
后台视图由 callback include全局全部可用

注意:

  • ⚠️ BLOG_URL 在 callback 里不要用(可能 CLI 触发)
  • ⚠️ Hook 在 callback 里不要用(用 plugin_activate 等核心钩子)

13.3 exit / header / die 边界表

钩子 / 场景能否 exit能否 header说明
主文件顶层❌❌只能定义符号
frontend/routes.php 回调入口✅✅路由端点标准位置
{插件名}_callback.php❌❌只做 DB 操作
渲染类钩子(template_head / body_end / admin_head / admin_footer / log_item_meta / log_display_after)❌ 红线 3❌布局已开始输出,中断毁整页
admin_request⚠️ 谨慎⚠️ 谨慎—
dispatch_start✅✅尚未输出
public_action_xxx✅ 必须✅ 必须AJAX / 表单端点标准位置
dispatch_plugin_route✅✅伪静态路由处理点
comment_save_before / comment_save_after❌❌数据层钩子
plugin_activate / plugin_deactivate❌❌生命周期事件

13.4 命名约定:全局 vs 命名空间

LiteLog 插件不使用 PHP 命名空间。 所有类、函数、常量都是全局的。

符号命名示例
函数{前缀}_{CamelCase}ICG_isEnabled()
常量{前缀}_UPPERICG_VER
类{Prefix}Camel 或 {Prefix}_CamelWidget_IconGen
表名{DB_PREFIX}{插件名}_{表名}lg_icon_generator_items
Option 键{插件名}_{键名}icon_generator_enabled

前缀选取:

  • 短且唯一:3-6 个字母,全大写(函数/常量)
  • 核心已占用 Forum_* / UserGroup_* / Uc_* / Widget_* / HomepageMode_*,不能重复

13.5 路径常量与斜杠约定

常量值示例结尾斜杠
LITELOG_ROOT/var/www/litelog/✅ 带
CONTENT_PATH/var/www/litelog/content/✅ 带
ADMIN_PATH/var/www/litelog/admin/✅ 带
INCLUDE_PATH/var/www/litelog/include/✅ 带
BLOG_URLhttps://example.com/✅ 带
DB_PREFIXlg_❌ 无
// ✅ 正确:常量结尾已带 /,不加前导斜杠
$file = CONTENT_PATH . 'plugins/my_plugin/data.json';
$url  = BLOG_URL . 'content/plugins/my_plugin/assets/admin.css';
$link = BLOG_URL . 'admin/?action=my_plugin';

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

// 表名拼接:DB_PREFIX 无下划线,需自己加
$table = DB_PREFIX . 'my_plugin_items';   // lg_my_plugin_items

第 14 章:★ API 参考(实测)

14.1 Database 类

见 9.1。

14.2 核心表清单

见 9.2。

14.3 核心 helper 函数完整清单

字符串 & 输出

函数签名说明
h($s)h(mixed): stringHTML 转义
stripTags($s, $len = 0)stripTags(string, int): string去标签
getExcerpt($html, $len = 200)getExcerpt(string, int): string提取摘要
makeAlias($str)makeAlias(string): stringURL 别名

时间

函数说明
dateFmt($ts)Y-m-d
datetimeFmt($ts)Y-m-d H:i

跳转 & 会话

函数说明
redirect($url)302 跳转 + exit(已自带 exit)
isLoggedIn()是否登录
checkLogin()强制登录

分页 & 请求

函数说明
pagination($page, $total, $perPage)分页 HTML
getPageNumber()当前页码
pageUrl($page)保留 GET 参数的分页链接

Option 类

Option::get($key, $default = '');   // ★ 第二参数永远用 '',不能传 null
Option::set($key, $value);
Option::all();

View 类

View::resolve($name);              // 返回主题模板文件路径
View::display($name, $data = []);  // 渲染并输出(extract $data)

Url 类

Url::log($gid, $alias);            // 文章链接
Url::sort($sid, $alias);           // 分类链接
Url::tag($name);                   // 标签链接
Url::author($uid);                 // 作者链接
Url::record($date);                // 归档链接

Hook 类

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

PluginRegistry 类

见 第 7 章。

核心模型

见 9.4。

不可用的函数

  • ❌ input() / request() / post() —— 直接读 $_GET / $_POST
  • ❌ json_response() / api_response() —— 自己 header + json_encode + exit
  • ❌ auth_user() / current_user() —— 用 $_SESSION['uid']
  • ❌ db() —— 用全局 $DB
  • ❌ config() —— 用 Option::get()

14.4 AJAX 端点八条铁律

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

标准 AJAX 端点模板

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

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

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

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

        global $DB;
        $pdo = null;
        try {
            $row = $DB->fetchOne(
                "SELECT id, owner_id FROM `" . DB_PREFIX . "my_items` WHERE id = ?",
                [$itemId]
            );
            if ($row === false) {
                echo json_encode(['ok' => false, 'msg' => '目标不存在'], JSON_UNESCAPED_UNICODE);
                exit;
            }

            $pdo = $DB->pdo();
            $pdo->beginTransaction();

            // 原子扣减(红线 5)
            $stmt = $DB->query(
                "UPDATE `" . DB_PREFIX . "user_profile`
                 SET credit = credit - ?
                 WHERE uid = ? AND credit >= ?",
                [$points, $uid, $points]
            );
            if ($stmt->rowCount() === 0) {
                $pdo->rollBack();
                echo json_encode(['ok' => false, 'msg' => '余额不足'], JSON_UNESCAPED_UNICODE);
                exit;
            }
            $DB->query(
                "UPDATE `" . DB_PREFIX . "user_profile` SET credit = credit + ? WHERE uid = ?",
                [$points, (int)$row['owner_id']]
            );

            $pdo->commit();

            echo json_encode(['ok' => true, 'msg' => '成功'], JSON_UNESCAPED_UNICODE);
            exit;
        } catch (Throwable $e) {
            try {
                if ($pdo && $pdo->inTransaction()) $pdo->rollBack();
            } catch (Throwable $ignored) {}

            @file_put_contents(
                CONTENT_PATH . 'backup/plugin-error.log',
                '[' . date('Y-m-d H:i:s') . "] MP_handleAjax: " . $e->getMessage() . "\n",
                FILE_APPEND | LOCK_EX
            );

            echo json_encode(['ok' => false, 'msg' => '服务器错误'], JSON_UNESCAPED_UNICODE);
            exit;
        }
    }
}

第 15 章:常见坑清单

15.1 安全

  1. 路径穿越 —— 文件名一律过 basename()
  2. SQL 注入 —— 必须占位符绑定或 (int) 强转
  3. XSS —— 输出必须过 h()
  4. IDOR —— 按 ID 操作必须校验资源存在
  5. 上传 —— 扩展名白名单 + 真实图片校验 + 随机文件名
  6. 开放重定向 —— redirect($url) 只允许本站 URL

15.2 部署兼容

  1. 不硬编码路径 —— URL 拼 BLOG_URL,文件路径拼 CONTENT_PATH / DIR
  2. 子目录部署 —— 假设站点可能在 https://example.com/blog/
  3. 大小写敏感文件系统 —— 目录名小写下划线

15.3 结构规范

  1. 一目录一插件 —— 目录名小写下划线
  2. 不留临时文件 —— .bak / .tmp / .old 一律清理
  3. 不改核心文件 —— 走 PluginRegistry + Hook
  4. 插件 option 加插件名前缀
  5. 生命周期文件不输出内容
  6. UTF-8 无 BOM,LF 行尾,php -l 校验
  7. ★ 主文件只做四件事:常量、函数、Hook 注册、require 子文件
  8. ★ 前台视图必须 include 主题 header/footer
  9. ★ CSS/JS 走 Hook 注入

15.4 性能

  1. 避免 N+1 —— 批量 ID 一次性 IN (...)
  2. 静态缓存 —— static 变量缓存同请求查询
  3. 钩子轻量化 —— 高频钩子禁止重查询
  4. 避免在高频钩子里做同步 IO

15.5 兼容性

  1. $DB 是全局 —— 函数里用必须 global $DB;
  2. $data 引用参数 —— log_save_before / comment_save_before 必须 function (&$data, ...)
  3. redirect() 已自带 exit —— 自定义跳转要自己加
  4. $_SESSION['uid'] 是登录标识
  5. ★ fetchOne() 失败返回 false(不是 null)
  6. ★ insert() 返回字符串 lastInsertId —— 必须 (int)
  7. ★ update() / delete() 返回 true(不是行数)

15.6 卸载自检

  1. 插件自建表已删(DROP TABLE IF EXISTS)
  2. option 表里本插件键已清(DELETE ... WHERE option_name LIKE 'xxx_%')
  3. 用户表中本插件写入的字段已清(如有)
  4. 插件 data/ 目录敏感文件已清(不删插件目录本身)

第 16 章:★ 调试方法与验证流程

★ v2.0 全新章节:从本次开发经验中提炼。

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

不要猜。要读。

症状第一步
后台 500打开 display_errors,看报错行号;查 content/backup/plugin-error.log
前台访问白屏F12 打开 Console,看是否有 JS 报错;看 Network 里 HTML 响应内容
路由不生效(访问 404)读 include/lib/dispatcher.php,看路由匹配逻辑
标题不对读主题的 header.php,看它读取哪个变量
CSS/JS 没加载F12 Network 面板,看请求是否发出、状态码是什么
数据没保存读 include/lib/mysql.php,看 update() / insert() 返回值含义

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
);

刷新页面,看 content/backup/debug.log 里的输出。

16.3 用 var_dump 短路调试

var_dump($page_title);   // 看变量到底有没有值
var_dump(get_defined_vars());  // 看视图里所有可用变量
exit;   // 临时中断,避免页面渲染

16.4 用 F12 排查前端

  • Console:看 JS 报错
  • Network:看 CSS/JS/API 请求是否 200
  • Elements:检查最终 DOM,确认 <head> 里是否有我们的 <link>
  • document.title:直接看最终标题变量

16.5 阅读内核源码的优先顺序

遇到问题时,按这个顺序读源码:

  1. index.php / admin/index.php —— 入口
  2. init.php —— 常量与初始化
  3. include/lib/dispatcher.php —— 前台路由
  4. include/lib/hook.php —— 钩子实现
  5. include/lib/function.common.php —— 插件加载
  6. include/lib/plugin_registry.php —— 注册表
  7. content/templates/{当前主题}/header.php —— 主题骨架
  8. include/model/*.php —— 模型

16.6 "参考实例对齐法"

有可工作参考时,先做结构对齐,再谈业务。

对齐清单:

  • [ ] 目录结构是否一致
  • [ ] 主文件是否只做四件事(常量 / 函数 / Hook / require)
  • [ ] 视图是否 include View::resolve('header')
  • [ ] CSS/JS 是否走 Hook 注入
  • [ ] 生命周期文件命名是否 {插件名}_callback.php
  • [ ] 路由注册是否走 PluginRegistry::addFrontendRoute

16.7 交付前的三步验证

  1. php -l 每个 .php 文件
  2. 启用 → 访问 → 停用 → 再启用 → 卸载 全流程走一遍
  3. F12 检查:Console 无报错、Network 全 200、Elements 结构正确

第 17 章:★ 完整最小骨架(可直接抄写)

AI 直接抄这份骨架,改文件名和业务逻辑即可。

17.1 目录结构

content/plugins/my_plugin/
├── my_plugin.php                    ← 主文件
├── my_plugin_callback.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!');

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

/* ---------- 配置读取 ---------- */
if (!function_exists('MP_conf')) {
    function MP_conf($key, $default = '') {
        if (!class_exists('Option')) return $default;
        try {
            $v = Option::get('my_plugin_' . $key, '');   // ★ 第二参数用 ''
        } catch (Throwable $e) {
            return $default;
        }
        return $v !== '' ? $v : $default;
    }
}

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

/* ---------- Hook 注册(罪 5) ---------- */
if (class_exists('Hook')) {
    try {
        /* 注入 CSS 到主题 <head> */
        Hook::addAction('template_head', 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";
        });

        /* 注入 JS 到 </body> 前 */
        Hook::addAction('body_end', 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) {
        @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
        );
    }
}

/* ---------- 加载路由文件(罪 7) ---------- */
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 $key => $val) {
                $optKey = 'my_plugin_' . $key;
                if (Option::get($optKey, '') === '') {
                    Option::set($optKey, $val);
                }
            }
        }
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] my_plugin callback_init: " . $e->getMessage() . "\n",
            FILE_APPEND | LOCK_EX
        );
    }
}

function callback_up() { callback_init(); }

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

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

17.4 frontend/routes.php

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

try {
    PluginRegistry::addFrontendRoute('my_plugin/?', function ($matches) {
        if (!function_exists('MP_isEnabled') || !MP_isEnabled()) {
            header('HTTP/1.0 404 Not Found');
            if (class_exists('View')) View::display('404');
            return;
        }

        $GLOBALS['__mp_active'] = true;
        include __DIR__ . '/../views/main.php';
    });
} catch (Throwable $e) {
    @file_put_contents(
        CONTENT_PATH . 'backup/plugin-error.log',
        '[' . date('Y-m-d H:i:s') . "] my_plugin route register: " . $e->getMessage() . "\n",
        FILE_APPEND | LOCK_EX
    );
}

17.5 views/main.php

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

/* 前置守卫(罪 4) */
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;
}

/* 设置页面标题(由主题 header.php 输出到 <title>) */
$__site = Option::get('site_title', '');
$page_title = '我的插件' . ($__site !== '' ? ' - ' . $__site : '');
$title      = $page_title;   // 双写,兼容不同主题

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

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

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

17.6 assets/style.css / assets/script.js

/* my_plugin styles */
.mp-container { max-width: 960px; margin: 0 auto; padding: 20px; }
/* my_plugin scripts */
(function () {
    'use strict';
    // 你的逻辑
})();

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


第 18 章:交付前自检清单

结构

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

安全

  • [ ] 所有 SQL 用占位符(规则 2)
  • [ ] 所有输出过 h()(规则 3)
  • [ ] 无文件操作,或文件操作过 basename()(规则 1)
  • [ ] AJAX 写操作有登录校验(红线 8)
  • [ ] 原子扣减用 query()->rowCount()(红线 5)
  • [ ] 无 unserialize / eval(红线 1)
  • [ ] IN (...) 用 array_map('intval') + 占位符(红线 6)
  • [ ] 未覆盖 PDO 属性(红线 7)

⭐ 500 免疫

  • [ ] 主文件每个 define() 有 if (!defined(...))(罪 1)
  • [ ] 主文件每个 function xxx() 有 if (!function_exists(...))(罪 1)
  • [ ] 所有 Option::get() 第二参数是 '',无 null(罪 2)
  • [ ] callback_init() 整段 try/catch,DDL 无 COMMENT / COLLATE(罪 3)
  • [ ] 视图顶部有 function_exists() 守卫(罪 4)
  • [ ] 所有 Hook::addAction() 包在 class_exists('Hook') + try/catch(罪 5)
  • [ ] PluginRegistry 调用前有 class_exists() 守卫(罪 6)
  • [ ] frontend/routes.php 在主文件里被 require_once(罪 7)

⭐ 视图与资源

  • [ ] 前台视图 include View::resolve('header') 和 footer(10.1)
  • [ ] 前台视图设置 $page_title(和 $title)
  • [ ] 前台视图不自建 <!DOCTYPE html><html><head>
  • [ ] CSS/JS 通过 Hook 注入,不是视图里直接 link(第 11 章)
  • [ ] 资源 URL 拼 BLOG_URL,带 ?v=filemtime

API 使用

  • [ ] $DB->fetchOne() 判 false(9.1)
  • [ ] $DB->insert() 返回值 (int) 转换(9.1)
  • [ ] 需要行数用 query()->rowCount(),不用 update() / delete() 返回值(9.1)
  • [ ] 用户名用 nickname ?: username
  • [ ] URL 拼接不重复加斜杠
  • [ ] 函数/常量/表名有插件前缀
  • [ ] AJAX 端点八条铁律全过(14.4)

验证

  • [ ] php -l 每个 .php 文件都过
  • [ ] 启用 → 访问 → 停用 → 再启用 → 卸载 全流程无异常
  • [ ] 浏览器标签页标题正确(如「图标生成器 - 站点名」)
  • [ ] F12 Console 无报错,Network 全 200
  • [ ] 卸载后表删除、option 清空

第 19 章:★ 开发经验教训(实测总结)

★ v2.0 全新章节:从 Icon Generator 插件的反复修复经历中提炼。

19.1 十条铁律

#教训
1规范是地图,内核是地形。地图和地形冲突时,信地形
2不确定的机制,先用 error_log / var_dump / 读源码验证,禁止靠猜
3有可工作参考实例时,先做"逐行结构对齐",再谈业务
4导出/文档没看到某物 ≠ 某物不存在;先问,再断言
5每条规范都有场景前提,问清上下文再套用
6少说"一定";多说"请验证" / "如未生效,查这里"
7"多键兜底""兼容写法"往往是懒惰的遮羞布;读源码胜过写兜底
8视图不能自建 HTML 骨架(前台视图必须 include 主题 header/footer)
9CSS/JS 走 Hook 注入(template_head / body_end),不是视图里直接 link
10内核只自动加载主文件;frontend/routes.php / admin/register.php 需主文件主动 require

19.2 常见错误推理链

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

正确推理链:路由不生效 → 读 dispatcher.php → 发现路由匹配在核心 → 检查 PluginRegistry::matchFrontendRoute 是否被调用 → 检查自己的注册代码 → 修复

错误推理链 B:标题不显示 → "可能变量名不对" → 猜 $title、$pageTitle、$GLOBALS → 猜 5 次

正确推理链:标题不显示 → 读主题 header.php 前 30 行 → 看它读哪个变量 → 设那个变量

19.3 开发流程建议

  1. 第一步:读参考实例(找同类型已工作插件,逐行对齐目录/主文件/视图/资源结构)
  2. 第二步:读内核关键文件(init.php / dispatcher.php / hook.php / 主题 header.php)
  3. 第三步:搭骨架(用第 17 章模板,先用 Hello World 验证路由通不通)
  4. 第四步:填业务(一次加一个功能,每加一个立即测试)
  5. 第五步:自检(用第 18 章清单逐条打钩)
  6. 第六步:交付(附上完整文件清单 + 使用说明)

19.4 反模式清单

不要做这些事:

  • ❌ 在视图里自建 <!DOCTYPE html><html><head><title>
  • ❌ 在视图里直接写 <link> / <script>(应该走 Hook)
  • ❌ 视图里直接调用插件函数而不做 function_exists 守卫
  • ❌ 在主文件顶层 echo / header() / 耗时 IO
  • ❌ 用 activate.php / deactivate.php / uninstall.php(不是内核约定)
  • ❌ 在 update() / delete() 返回 true 时判断 > 0
  • ❌ 忘记 (int) 转换 insert() 返回值
  • ❌ 忘记主文件 require_once frontend/routes.php
  • ❌ 猜变量名 / 猜 option 键名 / 猜函数名
  • ❌ 用"应该能行""这次一定"来交付

第 20 章:给 AI 的指令模板

20.1 从零开发新插件

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

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

需求:
- 功能:……
- 前台入口:URL ……,action=……
- 后台入口(可选):菜单名「……」,action=……
- 数据表:……

严格遵守本文件所有条款,特别是:
- 第 1 章「500 错误免疫」的 7 条口诀(最重要)
- 第 4 章「自动加载真相」(主文件必须 require 子文件)
- 第 10 章「视图必须 include 主题 header/footer」
- 第 11 章「CSS/JS 走 Hook 注入」
- 第 18 章交付前自检清单(逐条打钩)

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

20.2 修改已有插件

请在现有插件 XXX 的基础上增加「……功能」:

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

现有插件文件:(贴代码或说明路径)

20.3 排查 500 错误

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

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

报错信息:……

20.4 排错通用模板(★ v2.0 新增)

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

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

当前代码:(贴主文件 + 视图 + 路由)
内核相关信息:(贴相关内核文件,如 dispatcher.php / 主题 header.php)

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

20.5 只写一个独立函数 / AJAX 端点

请写一个 LiteLog 的 AJAX 端点,实现「……」:

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

要求:走 public_action_xxx 钩子,返回 JSON。
参考第 14.4 节「AJAX 端点八条铁律」。

收尾:一句话总结

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

外加三条 v2.0 核心原则:

视图 include 主题 header,资源走 Hook 注入,机制不明先读源码。

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


本文件基于 LiteLog v1.0.0 内核(2026-10-10 导出)逐行核对整理。v2.0 相对 v1.2 的主要修正:

  • PluginRegistry 真实存在(第 7 章)
  • 内核只自动加载主文件(第 4 章)
  • 生命周期文件是 {插件名}_callback.php(第 6 章)
  • 前台视图必须 include 主题 header/footer(第 10 章)
  • CSS/JS 通过 Hook 注入(第 11 章)
  • $DB->fetchOne() 失败返回 false;insert() 返回字符串;update() / delete() 返回 true(第 9 章)
  • 核心表名:tag_blog(不是 tag_relation),sort 主键是 sid(第 9 章)
  • Option 键名:site_title(不是 site_name)(第 9 章)
  • 新增第 16 章「调试方法」、第 19 章「开发经验教训」

若内核更新,请同步更新第 7 章「注册表」、第 8 章「钩子系统」、第 9 章「数据库」、第 14 章「API 参考」四章。

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