主站
论坛首页/ LiteLog /帖子

LiteLog 插件开发注意事项

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

作者:LiteLog 开发者

日期:2026-10-06
适用版本:LiteLog 1.0 + PHP 8.x


写在前面

从零开发 LiteLog 的插件系统,我踩了不少坑。有些坑显而易见,有些坑则隐藏很深——表面功能正常,实则每次请求都在最后一步崩溃。

这篇文章把所有踩过的坑都记录下来,分为 8 大类共 25 个具体问题,每个都给出现象、根因、修复。如果你也在开发类似的 PHP 插件系统,希望能帮你少走弯路。


目录

  1. 500 错误专题
  2. PHP 8 版本兼容专题
  3. 资源加载专题
  4. 编辑器开发专题
  5. 数据库专题
  6. 路由与伪静态专题
  7. 插件生命周期专题
  8. 经验总结与开发规范

一、500 错误专题

坑 1:插件目录结构不对,导致整站 500

现象:插件放到 content/plugins/ 下,启用后整站 500。

根因:插件主文件命名规则是 插件名/插件名.php(目录名和文件名必须一致),如果放错位置,loadActivePlugins() 找不到,或者误加载了错误的文件。

修复:

content/plugins/
└── seostats/
    ├── seostats.php          ← 主文件,名字必须与目录名一致
    └── seostats_callback.php

教训:约定大于配置,命名规则要严格执行。


坑 2:主文件里有 return,导致后续代码不执行

现象:加了一行"防重入"代码后,插件功能全部失效:

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

根因:return 在文件顶层会直接终止文件执行。如果这段在 Hook::addAction() 之前,后面的所有钩子注册和函数定义都不会执行。

修复:

不要用 return,改用 function_exists() 判断:

if (!function_exists('SeoStats_xxx')) {
    function SeoStats_xxx() { ... }
}

教训:文件级 return 是个大坑,插件里永远别用。


坑 3:ob_start('callback') 回调模式出错直接 500

现象:启用 SEO 插件后,每次请求都 500,但禁用后一切正常。

根因:我用了回调模式的输出缓冲:

ob_start('SeoStats_rewriteOutput');   // ← 危险!

回调模式下,PHP 会在页面输出完成时自动调用 SeoStats_rewriteOutput($html)。只要这个回调函数里任何一步抛错,PHP 就会在最后一步 Fatal Error → 500,而且无法被 try/catch 捕获。

修复:改用手动模式:

// 启动缓冲
function SeoStats_hookBufferStart() {
    if (ob_get_level() > 0) return;   // 防嵌套
    ob_start();
}

// 结束缓冲(自己处理)
function SeoStats_hookBufferEnd() {
    if (ob_get_level() === 0) return;
    $html = ob_get_clean();
    try {
        $html = SeoStats_rewriteOutput($html);
    } catch (Exception $e) {
    } catch (Throwable $e) {
    }
    echo $html;
}

教训:永远不用 ob_start('callback'),永远用自己的 try/catch 包住回调逻辑。


坑 4:ob_start 被嵌套调用

现象:页面内容重复输出,或者只剩一半。

根因:如果 dispatch_start 被多次触发(比如以后加了缓存层),ob_start() 会被嵌套多次,输出会层层叠加。

修复:启动前检查:

if (ob_get_level() > 0) return;   // 已经在缓冲中,别再开
ob_start();

教训:任何对 PHP 全局状态的操作(ob_、error_reporting、header)都要检查当前状态。


坑 5:插件头注释缺失导致识别失败

现象:插件文件放对了,但后台"插件"页面看不到它。

根因:scanPlugins() 用 parseHeader() 读插件注释头,注释头缺失 → 标题为空 → 被跳过。

修复:

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

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

教训:注释头必须是文件最前面的内容,前面不能有任何输出(包括空行、BOM)。


二、PHP 8 版本兼容专题

坑 6(最坑):未定义常量在 PHP 8 下是 Fatal Error

现象:插件在 PHP 7 下能用,换成 PHP 8 后点启用就 500。

根因:核心 admin/index.php 的 plugin_toggle 只加载 插件名_callback.php,不加载主文件。而 callback 里用了 SEOSTATS_VER 这个常量,它是在主文件里 define 的:

// seostats.php
define('SEOSTATS_VER', '1.0.1');

// seostats_callback.php
Option::set('seostats_tables_ver', SEOSTATS_VER);   // ← 常量未定义!
PHP 版本行为
PHP 7.xNotice + 当作字符串 "SEOSTATS_VER",不致命
PHP 8.xFatal Error → 500

修复:callback 文件顶部独立定义常量:

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

// ★ 主文件没加载时也能拿到
if (!defined('SEOSTATS_VER')) define('SEOSTATS_VER', '1.0.1');

教训:主文件和 callback 文件是独立加载的,不能共享常量、不能共享函数(除非用 function_exists 判断)。


坑 7:catch (Exception) 捕获不到 PHP 8 的 Error

现象:加了 try/catch,但 PHP 8 下依然 500。

根因:PHP 7 之后引入了 Error 类,它不继承自 Exception,而是和 Exception 平行:

Throwable (接口)
├── Exception
└── Error
    ├── TypeError
    ├── ParseError
    └── ...

catch (Exception $e) 无法捕获 Error。

修复:双 catch:

try {
    // 可能出错的代码
} catch (Exception $e) {
    // 处理 Exception
} catch (Throwable $e) {
    // 处理 Error(PHP 7+)
}

教训:所有 catch (Exception $e) 都要补一个 catch (Throwable $e),尤其涉及用户输入、动态调用、未定义常量时。


坑 8:fn() 箭头函数要求 PHP 7.4+

现象:代码在老版本 PHP 上报语法错误。

根因:用了 PHP 7.4 的新特性:

$fields = array_map(fn($f) => ':' . $f, $fieldNames);   // PHP 7.4+

修复:改用传统写法:

$fields = array_map(function ($f) { return ':' . $f; }, $fieldNames);

教训:兼容 PHP 7.1+,别用 fn()、match()、?->、enum 等新语法。


坑 9:?? 和 [] 解构需要 PHP 7.1+

根因:

[$a, $b] = $arr ?? ['x', 'y'];   // PHP 7.1+

修复:兼容写法:

$info = $arr ?? ['x', 'y'];
$a = $info[0];
$b = $info[1];

教训:除非确定 PHP 版本 ≥ 7.4,否则保守写。


三、资源加载专题

坑 10(核心 BUG):BLOG_URL 推导错误导致插件页 CSS 404

现象:插件设置页打开后完全没样式,左侧栏变白、链接乱跳、图标变形。

根因:init.php 里这样推导 BLOG_URL:

$dir = dirname($_SERVER['SCRIPT_NAME']);   // 用当前脚本路径推导
if (basename($dir) === 'admin') $dir = dirname($dir);
define('BLOG_URL', $scheme . '://' . $host . $dir . '/');
访问路径SCRIPT_NAME推导出的 BLOG_URL
/admin/?action=plugins/admin/index.php✅ http://li.zgzjw.top/
/content/plugins/novel/novel_setting.php那个文件❌ http://li.zgzjw.top/content/plugins/novel/

于是 CSS 变成:

http://li.zgzjw.top/content/plugins/novel/admin/assets/admin.css   → 404

修复:三层保险

$dir = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME']));

// 1) /admin/ → 向上
if (basename($dir) === 'admin') $dir = dirname($dir);

// 2) /content/plugins/xxx 或 /content/templates/xxx → 向上 2 层
if (preg_match('|/content/(plugins|templates)/[^/]+$|', $dir)) {
    $dir = preg_replace('|/content/(plugins|templates)/[^/]+$|', '', $dir);
}

// 3) 兜底:向上找 init.php
$docRoot = rtrim(str_replace('\\', '/', $_SERVER['DOCUMENT_ROOT']), '/');
$testDir = $dir;
$maxUp = 5;
while ($maxUp-- > 0) {
    if (file_exists($docRoot . $testDir . '/init.php')) {
        $dir = $testDir;
        break;
    }
    $parent = dirname($testDir);
    if ($parent === $testDir || $testDir === '/' || $testDir === '.') break;
    $testDir = $parent;
}

教训:全局常量 BLOG_URL 是插件、主题、所有页面共同依赖的,一旦推导错,全线崩溃。任何入口脚本都要能正确推导。


坑 11:CSS / JS 缓存导致修改不生效

现象:改了 CSS 上传后,浏览器打开还是旧样式。

根因:浏览器缓存了 CSS 文件。

修复:

  • 用户侧:Ctrl + Shift + R(强制刷新)
  • 开发者侧:给 CSS 加版本号:
<link rel="stylesheet" href="<?= BLOG_URL ?>admin/assets/admin.css?v=<?= LITELOG_VERSION ?>">

四、编辑器开发专题

坑 12:sticky + overflow: hidden 冲突,工具栏遮挡编辑区

现象:编辑器显示两个白框(工具栏 + 编辑区上下分离),鼠标点不进去编辑区。

根因:

.lite-editor { overflow: hidden; }               /* 父容器 */
.lite-editor-toolbar { position: sticky; top: 0; } /* 子元素 */

sticky 元素在 overflow: hidden 的父容器内无法相对视口定位,会"浮"到错误位置,遮住 contenteditable 编辑区。

修复:

.lite-editor { /* 去掉 overflow: hidden */ }
.lite-editor-toolbar {
    position: relative;       /* 不用 sticky */
    border-radius: 10px 10px 0 0;
}
.forum-editor-body { cursor: text; }   /* 明确可点击 */

教训:sticky 的父容器不能有 overflow: hidden。如果一定要 sticky,用 position: sticky 的祖先链上不能有 overflow 非 visible 的元素。


坑 13:Markdown 粘贴不转换

现象:把 Markdown 源码粘进编辑器,原样显示(# ** 都成了普通文本)。

根因:编辑器优先走 HTML 清洗路径。剪贴板里同时有 text/html 和 text/plain 时,text/html 里的 <p>| 表格 |</p> 被判定为"富文本",直接清洗后插入,不触发 Markdown 转换。

修复:调整优先级 —— Markdown 检测优先于 HTML 清洗:

this.body.addEventListener('paste', (e) => {
    const cd = e.clipboardData || window.clipboardData;
    const html = cd.getData('text/html');
    const text = cd.getData('text/plain');

    // ★ 优先级 1:Markdown
    if (text && looksLikeMarkdown(text)) {
        e.preventDefault();
        document.execCommand('insertHTML', false, markdownToHtml(text));
        return;
    }

    // 优先级 2:HTML 清洗
    if (html && /<(h[1-6]|p|ul|...)/i.test(html)) {
        e.preventDefault();
        document.execCommand('insertHTML', false, this._cleanPasteHtml(html));
        return;
    }
});

教训:检测逻辑要强于清洗逻辑。宁可先猜是 Markdown,也不要盲目保留 HTML。


坑 14:Markdown 表格检测过弱

现象:普通 Markdown 内容能转换,但表格粘贴后不转换。

根因:检测规则太弱:

if (/^\s*\|.*\|/.test(line)) score += 1;

表格每行只加 1 分,达不到阈值 4,就被当成普通文本。

修复:专门增强表格识别:

let tableRows = 0;
let tableSepFound = false;
const TABLE_SEP_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;

for (const line of lines) {
    if (/^\s*\|.*\|/.test(line)) {
        tableRows++;
        if (TABLE_SEP_RE.test(line)) tableSepFound = true;
    }
}

// 检测到分隔行 + ≥2 行表格 → 直接判定为 Markdown
if (tableSepFound && tableRows >= 2) return true;

教训:结构化内容(表格、列表、代码块)要用结构化规则检测,不能只靠单行加分。


坑 15:提交后表格格式全丢失

现象:编辑器里表格正常显示,提交后表格消失,变成纯文本。

根因:HTML 白名单没有 table 相关标签:

$allowed = ['b','strong','i','em','u','s',...];   // ← 没有 table/tr/td!

保存时 HTML 被清洗,<table> 整块被剥掉。

修复:扩充白名单:

$allowed = [
    // 文字
    'b','strong','i','em','u','s','del','strike','code','pre','sub','sup',
    // 块
    'p','div','blockquote','hr','br',
    // 标题
    'h1','h2','h3','h4','h5','h6',
    // 列表
    'ul','ol','li',
    // 链接图片
    'a','img',
    // ★ 表格
    'table','thead','tbody','tfoot','tr','th','td',
    // 其他
    'span','font',
];

教训:HTML 白名单要覆盖所有你想支持的标签。特别是结构标签(table、thead、tr、td),漏一个整块失效。


坑 16:style 属性被清空,对齐丢失

现象:表格的对齐、列宽都丢了。

根因:Forum_cleanNode() 一刀切移除了 style 属性。

修复:属性白名单 + style 内部过滤:

// 允许 style 属性
if ($child->hasAttribute('style')) {
    $style = $child->getAttribute('style');
    $safeProps = ['text-align','width','height','font-weight','font-style',
                  'text-decoration','color','background-color'];
    $final = [];
    foreach (explode(';', $style) as $rule) {
        $parts = explode(':', trim($rule), 2);
        if (count($parts) !== 2) continue;
        $prop = strtolower(trim($parts[0]));
        $val = trim($parts[1]);
        // 禁止 javascript / expression
        if (preg_match('/javascript:|expression|url\s*\(/i', $val)) continue;
        if (in_array($prop, $safeProps, true)) {
            $final[] = $prop . ': ' . $val;
        }
    }
    if ($final) $child->setAttribute('style', implode('; ', $final));
    else $child->removeAttribute('style');
}

教训:安全和功能不是二选一。属性白名单 + 值过滤,能兼得。


五、数据库专题

坑 17:content_md 字段缺失,保存文章 500

现象:后台写文章 → 保存 → 500。

根因:数据库 lt_blog 表缺少 content_md 字段(旧版本建的库),但代码 INSERT 时尝试写入这一列。

修复:

ALTER TABLE `lt_blog` ADD COLUMN `content_md` LONGTEXT AFTER `content`;

教训:任何字段变更都要能兜底。理想方案:核心首次加载时检查关键字段,缺则自动 ALTER。


坑 18:建表失败被静默 catch,但版本号被写入

现象:插件"启用成功",但某张表实际没建。之后访问对应页面 500。

根因:

try {
    seostats_buildTables();
    Option::set('seostats_tables_ver', SEOSTATS_VER);   // ← 建表失败也执行
} catch (Exception $e) {
    // 静默
}

建表过程中某条 SQL 失败(比如权限问题),被 catch 掉,但版本号照样写入。下次加载看到版本匹配就跳过建表,缺表永远缺着。

修复:版本号写入前必须验证表存在:

$requiredTables = ['seostats_daily','seostats_visitor','seostats_article',
                   'seostats_category','seostats_push_log'];
$needRebuild = false;
foreach ($requiredTables as $t) {
    try {
        $DB->fetchColumn("SELECT 1 FROM `{$P}{$t}` LIMIT 1");
    } catch (Exception $e) {
        $needRebuild = true;
        break;
    } catch (Throwable $e) {
        $needRebuild = true;
        break;
    }
}

// 表全在且版本匹配 → 才跳过
if (!$needRebuild && Option::get('seostats_tables_ver', '') === SEOSTATS_VER) return;

// 否则重建
seostats_buildTables();
Option::set('seostats_tables_ver', SEOSTATS_VER);

教训:"成功"的定义要严格。版本号写入 = 所有前置条件都满足,不是"代码跑完没抛异常"。


坑 19:SQL 中 ORDER BY 用了不存在的字段

现象:分类 SEO 页面 500,错误:Unknown column 'sort' in 'order clause'。

根因:插件查询 lt_sort 表时用了 ORDER BY sort,但核心 lt_sort 表默认没有 sort 字段。

修复:两种方案

方案 A:给核心表加字段

ALTER TABLE `lt_sort` ADD COLUMN `sort` INT DEFAULT 0 AFTER `pid`;

方案 B:改插件 SQL

// 改成
$sorts = $DB->fetchAll("SELECT sid,sortname FROM {$P}sort ORDER BY sid ASC");

教训:插件查询核心表前,先确认字段存在。最好在插件文档里写明依赖的字段。


坑 20:表前缀写死

根因:SQL 里写了 lt_blog,但用户配置的前缀是 myprefix_。

修复:永远用 DB_PREFIX:

$P = DB_PREFIX;
$DB->fetchAll("SELECT * FROM {$P}blog");

教训:不要假设前缀。


六、路由与伪静态专题

坑 21:Nginx 正则中的 {} 被当配置块

现象:保存伪静态报错:

nginx: [emerg] directive "rewrite" is not terminated by ";"

根因:正则里的 \d{4} 和 \d{2} 中的 { } 被 Nginx 当成配置块的起止符,还没解析到 ; 就报错。

修复:给带 {} 的正则整体加双引号:

# ❌ 错误
rewrite ^/record/(\d{4}-\d{2})/?$ /index.php?record=$1 last;

# ✅ 正确
rewrite "^/record/(\d{4}-\d{2})/?$" /index.php?record=$1 last;

教训:Nginx 正则里带 {} 就必须加引号。


坑 22:装一个插件改一次伪静态

现象:装小说插件要加 /novel 规则,装论坛插件要加 /forum 规则……每装一个都得改 Nginx。

根因:没有通用路由机制,每个插件的 URL 都要单独配。

修复:核心侧统一处理:

# 一次性配置,永久生效
rewrite ^/([a-zA-Z][a-zA-Z0-9_-]*)/?$ /index.php?__plugin_path=$1 last;
rewrite ^/([a-zA-Z][a-zA-Z0-9_-]*)/(.+)$ /index.php?__plugin_path=$1&__plugin_rest=$2 last;

Dispatcher 优先处理:

if (!empty($_GET['__plugin_path'])) {
    $pluginName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['__plugin_path']);
    $rest = $_GET['__plugin_rest'] ?? '';
    $GLOBALS['__plugin_routed'] = false;
    Hook::doAction('dispatch_plugin_route', $pluginName, $rest);
    if (!empty($GLOBALS['__plugin_routed'])) {
        Hook::doAction('dispatch_end');
        return;
    }
    // 插件目录不存在 → 走标准路由
}

插件侧监听:

Hook::addAction('dispatch_plugin_route', 'MyPlugin_route');

function MyPlugin_route($pluginName, $rest) {
    if ($pluginName !== 'my_plugin') return;
    $GLOBALS['__plugin_routed'] = true;
    // 处理 /my_plugin/xxx
}

教训:核心要提供通用机制,让扩展者只关注业务,不碰基础设施。


七、插件生命周期专题

坑 23:callback_init() 从来没被调用

现象:后台启用插件后,数据库里没建表,访问插件页面就 500。

根因:核心 plugin_toggle 只写了 Hook::doAction('plugin_activate', $name),从来没加载过 插件名_callback.php,也没调用 callback_init()。

修复:修改核心 plugin_toggle:

} else {
    // 启用
    $activeList[] = $name;
    saveActivePlugins($activeList);

    // ★ 真正执行初始化
    if (is_file($callbackFile)) {
        require_once $callbackFile;
        if (function_exists('callback_init')) {
            try {
                callback_init();
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
        }
    }
    Hook::doAction('plugin_activate', $name);
}

教训:规范文档和实际实现要对齐。文档里写了 callback_init() 会在启用时调用,代码就必须调,否则所有插件都踩坑。


坑 24:禁用插件后,某些钩子还在生效

根因:PHP 里 Hook::addAction() 一旦注册就无法撤销(除了 removeAction()),而插件的钩子函数可能已经被 require_once 加载了。

修复:loadActivePlugins() 只加载 active_plugins 里的插件:

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

注意:require_once 一次加载后,同一请求内无法卸载。所以禁用后需要刷新页面才生效。

教训:PHP 的模块加载机制决定了插件热卸载几乎不可能,只能"下次请求不加载"。


坑 25:插件头部注释的 Description 字段过长影响显示

现象:插件卡片描述被截断,或者排版错乱。

修复:用 CSS -webkit-line-clamp 限制行数:

.plugin-card-desc {
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

教训:描述字段建议 60 字以内,超出部分前台用省略号。


八、经验总结与开发规范

8.1 PHP 8 兼容清单

项PHP 7.xPHP 8.x建议
未定义常量NoticeFatal Error用 defined() 先判断
catch (Exception)捕获 Exception不捕获 Error补 catch (Throwable)
fn() 箭头函数7.4+✅用 function(){} 兼容 7.1
match()8.0+✅用 switch 兼容
?-> nullsafe8.0+✅用 ?? + ? 判断
str_contains8.0+✅用 strpos() !== false

8.2 插件开发十条铁律

  1. 主文件和 callback 文件独立:常量各自 define,函数各自 function_exists
  2. 不用顶层 return:用 function_exists 判断代替
  3. 不用 ob_start('callback'):用手动 ob_start + try/catch
  4. 所有可能出错的地方加双 catch:catch (Exception) + catch (Throwable)
  5. 建表前先验证:用 SHOW TABLES 检查,不能盲目相信版本号
  6. SQL 永远用 DB_PREFIX:不假设表前缀
  7. HTML 白名单要覆盖全:结构标签(table、ul、pre)漏一个整块失效
  8. style 属性用白名单:允许安全属性,过滤 javascript: / expression
  9. BLOG_URL 推导要兜底:向上找 init.php 是最后防线
  10. Nginx 正则里带 {} 加引号:否则整条规则报错

8.3 插件标准结构模板

content/plugins/my_plugin/
├── my_plugin.php              ← 主文件(含头部注释)
├── my_plugin_callback.php     ← 生命周期(建表 + 初始化)
├── my_plugin_setting.php      ← 后台设置页(可选)
├── assets/                    ← 静态资源(可选)
│   ├── style.css
│   └── script.js
└── preview.jpg                ← 预览图(可选)

my_plugin.php 模板:

<?php
/**
 * Plugin Name: 我的插件
 * Description: 一句话描述
 * Version: 1.0.0
 * Author: 你
 */

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

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

// 建表自检
if (!function_exists('MyPlugin_ensureTables')) {
    function MyPlugin_ensureTables() {
        global $DB;
        $P = DB_PREFIX;
        try {
            $DB->fetchColumn("SELECT 1 FROM `{$P}myplugin_table` LIMIT 1");
        } catch (Exception $e) {
            // 表不存在 → 建
            $cb = __DIR__ . '/my_plugin_callback.php';
            if (is_file($cb)) {
                require_once $cb;
                if (function_exists('myplugin_buildTables')) {
                    myplugin_buildTables();
                }
            }
        } catch (Throwable $e) {
            // 同上
        }
    }
}
MyPlugin_ensureTables();

// 注册钩子
Hook::addAction('dispatch_plugin_route', 'MyPlugin_dispatchRoute');
Hook::addAction('admin_menu',            'MyPlugin_adminMenu');
Hook::addAction('admin_request',         'MyPlugin_adminRoute');

// 函数定义都用 function_exists 包裹
if (!function_exists('MyPlugin_dispatchRoute')) {
    function MyPlugin_dispatchRoute($pluginName, $rest) {
        if ($pluginName !== 'my_plugin') return;
        $GLOBALS['__plugin_routed'] = true;
        // 路由处理
    }
}

my_plugin_callback.php 模板:

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

// ★ 关键:主文件可能没加载,常量必须独立定义
if (!defined('MYPLUGIN_VER')) define('MYPLUGIN_VER', '1.0.0');

if (!function_exists('callback_init')) {
    function callback_init() {
        try {
            myplugin_buildTables();
            Option::set('myplugin_tables_ver', MYPLUGIN_VER);
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }
}

if (!function_exists('myplugin_buildTables')) {
    function myplugin_buildTables() {
        global $DB;
        $P = DB_PREFIX;
        $sqls = [
            "CREATE TABLE IF NOT EXISTS `{$P}myplugin_table` (...) ENGINE=InnoDB",
        ];
        foreach ($sqls as $sql) {
            try {
                $DB->query($sql);
            } catch (Exception $e) {
                // 写日志
            } catch (Throwable $e) {
            }
        }
    }
}

8.4 调试速查

遇到 500 的排查顺序:

  1. 打开 display_errors:init.php 里临时改
  2. 看错误日志:content/backup/sql-error.log、plugin-error.log
  3. 看 PHP 版本:宝塔 → 站点 → PHP 版本(≥ 7.4)
  4. 检查表结构:SHOW TABLES、DESC xxx
  5. 检查字段:核心表是否缺字段
  6. 检查路由:.htaccess / Nginx 规则
  7. 检查权限:content/ 目录是否可写

结语

插件开发,80% 的时间花在调试 500 上。这些错误往往不显眼,但一旦踩上,就会消耗大量时间。

回顾整个开发过程,最深刻的三个教训:

  1. PHP 8 的 Fatal Error 是无声的杀手——未定义常量、未捕获的 Error,让代码在最不该崩的时刻崩掉
  2. 规范文档必须和实际实现严格对齐——文档说 callback_init() 会在启用时调用,代码就必须调
  3. 核心要提供基础设施——BLOG_URL 推导、通用路由、输出缓冲,这些都应该由核心保证,插件只管业务

希望这篇总结能帮到正在开发或准备开发插件的人。踩坑不可怕,可怕的是一遍遍踩同一个坑。


© 2026 LiteLog Team

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