作者:LiteLog 开发者
日期:2026-10-06
适用版本:LiteLog 1.0 + PHP 8.x
写在前面
从零开发 LiteLog 的插件系统,我踩了不少坑。有些坑显而易见,有些坑则隐藏很深——表面功能正常,实则每次请求都在最后一步崩溃。
这篇文章把所有踩过的坑都记录下来,分为 8 大类共 25 个具体问题,每个都给出现象、根因、修复。如果你也在开发类似的 PHP 插件系统,希望能帮你少走弯路。
目录
一、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.x | Notice + 当作字符串 "SEOSTATS_VER",不致命 |
| PHP 8.x | Fatal 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.x | PHP 8.x | 建议 |
|---|---|---|---|
| 未定义常量 | Notice | Fatal Error | 用 defined() 先判断 |
catch (Exception) | 捕获 Exception | 不捕获 Error | 补 catch (Throwable) |
fn() 箭头函数 | 7.4+ | ✅ | 用 function(){} 兼容 7.1 |
match() | 8.0+ | ✅ | 用 switch 兼容 |
?-> nullsafe | 8.0+ | ✅ | 用 ?? + ? 判断 |
str_contains | 8.0+ | ✅ | 用 strpos() !== false |
8.2 插件开发十条铁律
- 主文件和 callback 文件独立:常量各自
define,函数各自function_exists - 不用顶层
return:用function_exists判断代替 - 不用
ob_start('callback'):用手动ob_start+ try/catch - 所有可能出错的地方加双 catch:
catch (Exception)+catch (Throwable) - 建表前先验证:用
SHOW TABLES检查,不能盲目相信版本号 - SQL 永远用
DB_PREFIX:不假设表前缀 - HTML 白名单要覆盖全:结构标签(table、ul、pre)漏一个整块失效
style属性用白名单:允许安全属性,过滤javascript:/expressionBLOG_URL推导要兜底:向上找init.php是最后防线- 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 的排查顺序:
- 打开
display_errors:init.php里临时改 - 看错误日志:
content/backup/sql-error.log、plugin-error.log - 看 PHP 版本:宝塔 → 站点 → PHP 版本(≥ 7.4)
- 检查表结构:
SHOW TABLES、DESC xxx - 检查字段:核心表是否缺字段
- 检查路由:
.htaccess/ Nginx 规则 - 检查权限:
content/目录是否可写
结语
插件开发,80% 的时间花在调试 500 上。这些错误往往不显眼,但一旦踩上,就会消耗大量时间。
回顾整个开发过程,最深刻的三个教训:
- PHP 8 的 Fatal Error 是无声的杀手——未定义常量、未捕获的 Error,让代码在最不该崩的时刻崩掉
- 规范文档必须和实际实现严格对齐——文档说
callback_init()会在启用时调用,代码就必须调 - 核心要提供基础设施——
BLOG_URL推导、通用路由、输出缓冲,这些都应该由核心保证,插件只管业务
希望这篇总结能帮到正在开发或准备开发插件的人。踩坑不可怕,可怕的是一遍遍踩同一个坑。
© 2026 LiteLog Team