LiteLog 插件开发规范 · 附录 A:《API 参考》
用途:本附录是《LiteLog 插件开发规范》的配套材料,自包含——任何 AI 拿到规范正文 + 本附录后,无需再阅读核心代码即可完成插件开发。 版本:1.0(2026-10-11),配套规范 v1.1 给 AI 的指令:规范正文 + 本附录一并附上。
A.0 使用说明
本附录分 10 节,对应十个「不读核心代码就一定会踩」的坑:
| 节 | 主题 | 优先级 |
|---|---|---|
| A.1 | 文件加载时机表(每个文件里能用什么符号) | P0 |
| A.2 | $DB API 精确签名 | P0 |
| A.3 | 核心表关键字段清单 | P0 |
| A.4 | 完整最小插件骨架(可抄写模板) | P0 |
| A.5 | exit / header / die 边界表 | P1 |
| A.6 | AJAX 端点完整范例 | P1 |
| A.7 | 路径常量与斜杠约定 | P1 |
| A.8 | 命名约定:全局 vs 命名空间 | P2 |
| A.9 | 视图可用变量清单 | P2 |
| A.10 | 核心 helper 函数完整清单 | P2 |
约定:本附录中所有「已可用符号」都经过LiteLog v1.0.0 内核实际加载顺序核实。若内核更新,请同步更新本附录。
A.1 文件加载时机表
AI 用这张表判断:写某个文件时,某个符号能不能直接用。
A.1.1 请求生命周期(前台)
请求进入 index.php
↓
init.php 执行
├─ 定义常量:LITELOG_ROOT / CONTENT_PATH / ADMIN_PATH / INCLUDE_PATH / BLOG_URL / DB_PREFIX
├─ 加载核心 lib:Database($DB) / Option / Hook / 模型类 / helper 函数
├─ 建立 PDO 连接 → $DB 可用
└─ 加载插件:主文件 → frontend/routes.php(若存在)
↓
Dispatcher::dispatch()
├─ Hook::doAction('dispatch_start') ← 插件可在此挂载
├─ 路由匹配
├─ Hook::doAction('public_action_{action}') ← ?action=xxx 分发钩子
└─ 渲染
A.1.2 各文件已可用符号表
| 文件 | 触发时机 | 已可用符号 | 尚未可用 |
|---|---|---|---|
主文件 {插件名}.php | init 后、路由前 | 常量、$DB、Option、Hook、模型类、h()、redirect()、isLoggedIn()、checkLogin() | $_GET['action'] 尚未确定;$_SESSION['uid'] 已可用 |
frontend/routes.php | 同主文件 | 上述全部 + PluginRegistry | — |
admin/register.php | 后台请求时 | 上述全部 + PluginRegistry、adminIcon()、BLOG_URL | — |
activate.php | 后台点「启用」时 | LITELOG_ROOT、CONTENT_PATH、DB_PREFIX、$DB、Option | Hook 可能未加载;BLOG_URL 不可靠 |
deactivate.php | 后台点「停用」时 | 同 activate | — |
uninstall.php | 后台点「卸载」时 | 同 activate | — |
**后台视图 admin/pages/.php* | 由 callback include | 全局全部可用 | — |
**前台视图 views/.php* | 由路由 callback include | 全局全部可用 + 传入的 $data | — |
A.1.3 关键提醒
BLOG_URL在activate.php里不要用:生命周期脚本可能是 CLI 触发(未来扩展),BLOG_URL依赖$_SERVER。Hook在生命周期文件里不要用:官方推荐在生命周期脚本里只做 DB 操作,事件通知走plugin_activate等核心钩子(由核心在执行完脚本后触发)。$_SESSION在主文件里可用,但不要在主文件顶层直接读(此时尚未 login 判定);应在 Hook 回调里读。
A.2 $DB API 精确签名
AI 写任何 SQL 都先查这张表。
A.2.1 方法总览
global $DB;
// —— 查询 ——
$stmt = $DB->query($sql, $params = []); // PDOStatement
$rows = $DB->fetchAll($sql, $params = []); // array(无结果时为 [])
$row = $DB->fetchOne($sql, $params = []); // array | null
$scalar = $DB->fetchColumn($sql, $params = []); // mixed | false
// —— 写操作 ——
$insertId = $DB->insert($table, $data); // int(自增主键)| false
$affected = $DB->update($table, $data, $where, $whereParams = []); // int(受影响行数)| false
$affected = $DB->delete($table, $where, $params = []); // int(受影响行数)| false
// —— 元信息 ——
$prefix = $DB->prefix(); // 返回 DB_PREFIX(字符串,结尾无下划线,如 'lg_')
$pdo = $DB->pdo(); // 原生 PDO 对象
// —— 事务(透传 PDO)——
$pdo->beginTransaction();
$pdo->commit();
$pdo->rollBack();
$pdo->inTransaction(); // bool
A.2.2 精确语义
| 方法 | 参数 2 语义 | 返回值 | 注意 |
|---|---|---|---|
query($sql, $params) | 有 $params → 走 prepare/execute;无 → 走 exec | PDOStatement | 可 ->rowCount() / ->fetchColumn() |
fetchAll($sql, $params) | 同上 | array(空时 []) | 永远是数组,不必判 null |
fetchOne($sql, $params) | 同上 | array \| null | 找不到返回 null,不是 false |
fetchColumn($sql, $params) | 同上 | 第一列标量 \ | false |
insert($table, $data) | — | lastInsertId 的 int;失败抛异常或 false | $data 是关联数组,键=列名 |
update($table, $data, $where, $wp) | $where 是不带 WHERE 关键字的字符串(如 'id=?') | 受影响行数 int | $wp 是 WHERE 的参数数组 |
delete($table, $where, $params) | $where 同 update | 受影响行数 int | — |
prefix() | — | DB_PREFIX 字符串 | 直接拼 SQL 时用 |
A.2.3 完整示例
global $DB;
// 1) 查一条
$row = $DB->fetchOne(
"SELECT * FROM `" . DB_PREFIX . "user` WHERE uid = ?",
[(int)$uid]
);
if ($row === null) { /* 不存在 */ }
// 2) 查多条
$rows = $DB->fetchAll(
"SELECT * FROM `" . DB_PREFIX . "blog` WHERE `sort_id` = ? ORDER BY `date` DESC LIMIT 10",
[(int)$sid]
);
// 3) 标量
$count = (int)$DB->fetchColumn(
"SELECT COUNT(*) FROM `" . DB_PREFIX . "user` WHERE `status` = 1"
);
// 4) 插入
$newId = $DB->insert('friend_links', [
'name' => 'Google',
'url' => 'https://google.com',
'sort_order' => 0,
'visible' => 1,
'created_at' => time(),
]);
if ($newId === false) { /* 插入失败 */ }
// 5) 更新
$affected = $DB->update(
'friend_links',
['name' => 'Google Inc'],
'id = ?',
[(int)$id]
);
// 6) 删除
$affected = $DB->delete('friend_links', 'id = ?', [(int)$id]);
// 7) IN 查询(红线 6:必须 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
);
}
// 8) 事务
$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; // 或记日志
}
A.2.4 常见陷阱
- ❌
if ($DB->delete(...))——delete()返回0时也代表「没有行被删」,但0在 PHP 里是 falsy,会误判为「删除失败」。正确做法是if ($DB->delete(...) > 0)。 - ❌
if ($row = $DB->fetchOne(...))——fetchOne()找不到返回null,但如果你在 if 里赋值,会隐式判空。写if (($row = $DB->fetchOne(...)) !== null)更明确。 - ❌
(int)$DB->fetchColumn(...)遇false会变成0—— 通常没问题,但如果0是合法值会混淆,用!== false先判。
A.3 核心表关键字段清单
AI 写 JOIN / 聚合 / 覆盖模型无法处理的场景时查这张表。
A.3.1 {DB_PREFIX}user(用户主表)
| 列 | 类型 | 说明 |
|---|---|---|
uid | INT UNSIGNED PK | 用户 ID(注意是 uid 不是 id) |
username | VARCHAR | 登录名 |
nickname | VARCHAR | 昵称(显示优先用 nickname ?: username) |
email | VARCHAR | 邮箱 |
password | VARCHAR | 密码 hash |
status | TINYINT | 1=正常,0=禁用 |
role | VARCHAR | 'admin' 为管理员 |
reg_time | INT UNSIGNED | 注册时间戳 |
A.3.2 {DB_PREFIX}user_profile(用户资料扩展表)
| 列 | 类型 | 说明 |
|---|---|---|
uid | INT UNSIGNED PK | 对应用户 ID |
credit | INT | 积分(点赞送分等场景操作此列) |
avatar | VARCHAR | 头像 URL |
level | INT | 等级 |
bio | TEXT | 简介 |
⚠️ 该表行可能不存在。写代码时先SELECT COUNT(*) WHERE uid=?,为 0 则先INSERT ... (uid, credit) VALUES (?, 0)再操作。
A.3.3 {DB_PREFIX}blog(文章表)
| 列 | 类型 | 说明 |
|---|---|---|
gid | INT UNSIGNED PK | 文章 ID(注意是 gid 不是 id) |
title | VARCHAR | 标题 |
content | LONGTEXT | 正文(HTML) |
alias | VARCHAR | URL 别名 |
sort_id | INT | 分类 ID |
uid | INT | 作者 UID |
date | INT UNSIGNED | 发布时间戳 |
views | INT UNSIGNED | 浏览量 |
hide | TINYINT | 1=隐藏 |
A.3.4 {DB_PREFIX}comment(评论表)
| 列 | 类型 | 说明 |
|---|---|---|
cid | INT UNSIGNED PK | 评论 ID |
gid | INT | 所属文章 ID |
pid | INT | 父评论 ID(0 = 顶级) |
uid | INT | 评论者 UID(0 = 游客) |
author | VARCHAR | 游客名 |
content | TEXT | 内容 |
date | INT UNSIGNED | 时间戳 |
status | TINYINT | 1=可见 |
A.3.5 {DB_PREFIX}option(配置表)
| 列 | 类型 | 说明 |
|---|---|---|
option_name | VARCHAR PK | 键名 |
option_value | TEXT | 值(永远是字符串) |
⚠️Option::get()返回值永远是 string,不是 int / bool / array。enabled存'0'/'1'字符串,判断用=== '1'。
A.3.6 {DB_PREFIX}sort / {DB_PREFIX}tag / {DB_PREFIX}tag_relation
| 表 | 主键 | 关键列 |
|---|---|---|
sort | sid | name / alias / sort_order |
tag | tid | name |
tag_relation | id | gid / tid |
A.3.7 论坛插件表(若安装了 forum 插件)
| 表 | 主键 | 关键列 |
|---|---|---|
{前缀}forum_board | id | name / description / sort / thread_count / post_count |
{前缀}forum_thread | id | board_id / uid / title / content / views / reply_count / is_top / is_essence / is_locked / highlight_color / last_reply_time / last_reply_uid / create_time |
{前缀}forum_post | id | thread_id / uid / content / create_time |
A.3.8 用户资料表存在性检查模板
function ensureUserProfile($uid) {
global $DB;
$uid = (int)$uid;
if ($uid <= 0) return false;
$exists = (int)$DB->fetchColumn(
"SELECT COUNT(*) FROM `" . DB_PREFIX . "user_profile` WHERE uid = ?",
[$uid]
);
if ($exists === 0) {
$DB->query(
"INSERT INTO `" . DB_PREFIX . "user_profile` (uid, credit) VALUES (?, 0)",
[$uid]
);
}
return true;
}
A.4 完整最小插件骨架
AI 直接抄这份骨架,改文件名和业务逻辑即可。 包含:主文件 + 生命周期 + 后台注册 + 后台视图 + 前台资源。
A.4.1 目录结构
content/plugins/my_plugin/
├── my_plugin.php ← 主文件
├── activate.php ← 启用时建表
├── deactivate.php ← 停用时(空操作)
├── uninstall.php ← 卸载时删表
├── admin/
│ ├── register.php ← 后台菜单/页面注册
│ └── pages/
│ └── settings.php ← 后台设置页视图
└── assets/
├── admin.css
├── admin.js
├── front.css
└── front.js
A.4.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 (!defined('MP_MAX_ITEMS')) define('MP_MAX_ITEMS', 100);
/* ---------- 配置读取 ---------- */
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';
}
}
if (!function_exists('MP_table')) {
function MP_table() {
return DB_PREFIX . 'my_plugin_items';
}
}
/* ---------- 业务逻辑 ---------- */
if (!function_exists('MP_getItems')) {
function MP_getItems($limit = 10) {
global $DB;
$limit = (int)$limit;
if ($limit <= 0 || $limit > 100) $limit = 10;
try {
return $DB->fetchAll(
"SELECT * FROM `" . MP_table() . "` WHERE visible = 1 ORDER BY sort_order ASC LIMIT $limit"
);
} catch (Throwable $e) {
return [];
}
}
}
/* ---------- Hook 注册(必须包 try/catch) ---------- */
if (class_exists('Hook')) {
try {
// 示例:在文章详情页底部注入一段 HTML
Hook::addAction('log_display_after', function ($log) {
if (!MP_isEnabled()) return;
$items = MP_getItems(5);
if (!$items) return;
echo '<div class="mp-block"><h3>推荐</h3><ul>';
foreach ($items as $it) {
echo '<li>' . h($it['name']) . '</li>';
}
echo '</ul></div>';
});
} 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
);
}
}
A.4.3 activate.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
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 '',
`sort_order` INT NOT NULL DEFAULT 0,
`visible` TINYINT NOT NULL DEFAULT 1,
`created_at` INT UNSIGNED NOT NULL DEFAULT 0,
KEY `idx_sort` (`sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if (class_exists('Option')) {
foreach ([
'enabled' => '1',
'title' => '我的插件',
] as $key => $val) {
if (Option::get('my_plugin_' . $key, '') === '') {
Option::set('my_plugin_' . $key, $val);
}
}
}
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin activate: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
A.4.4 deactivate.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
// 停用是「临时关闭」:不删表、不删数据。
A.4.5 uninstall.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
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) {}
A.4.6 admin/register.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('PluginRegistry')) return;
try {
PluginRegistry::addAdminMenu([
'id' => 'my_plugin',
'action' => 'my_plugin',
'title' => '我的插件',
'icon' => 'settings',
'priority' => 30,
]);
PluginRegistry::addAdminPage('my_plugin', [
'title' => '我的插件设置',
'callback' => function () {
include __DIR__ . '/pages/settings.php';
},
]);
} catch (Throwable $e) {
@file_put_contents(
CONTENT_PATH . 'backup/plugin-error.log',
'[' . date('Y-m-d H:i:s') . "] my_plugin admin register: " . $e->getMessage() . "\n",
FILE_APPEND | LOCK_EX
);
}
A.4.7 admin/pages/settings.php
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
/* ★ 前置守卫:主文件函数必须存在 */
if (!function_exists('MP_isEnabled') || !function_exists('MP_table')) {
echo '<div class="card"><div class="card-title">插件未正确加载</div>'
. '<p style="color:#b91c1c">主文件 my_plugin.php 未加载,请检查插件目录与激活状态。</p></div>';
return;
}
$error = '';
$success = '';
/* ---------- POST 处理 ---------- */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['mp_save'])) {
$title = trim((string)($_POST['title'] ?? ''));
if (mb_strlen($title) > 60) {
$error = '标题过长';
} else {
Option::set('my_plugin_title', $title);
Option::set('my_plugin_enabled', isset($_POST['enabled']) ? '1' : '0');
$success = '设置已保存';
}
}
/* ---------- 读取配置 ---------- */
$title = MP_conf('title', '我的插件');
$enabled = MP_isEnabled();
/* ---------- 数据查询 ---------- */
global $DB;
$items = [];
try {
$items = $DB->fetchAll(
"SELECT * FROM `" . MP_table() . "` ORDER BY sort_order ASC LIMIT 20"
);
} catch (Throwable $e) {}
/* ---------- 后台资源(视图内直接输出) ---------- */
$cssVer = @filemtime(CONTENT_PATH . 'plugins/my_plugin/assets/admin.css') ?: 1;
$jsVer = @filemtime(CONTENT_PATH . 'plugins/my_plugin/assets/admin.js') ?: 1;
?>
<link rel="stylesheet" href="<?= h(BLOG_URL . 'content/plugins/my_plugin/assets/admin.css') ?>?v=<?= (int)$cssVer ?>">
<?php if ($error): ?>
<div class="card" style="border-left:3px solid #dc2626;color:#b91c1c"><?= h($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="card" style="border-left:3px solid #16a34a;color:#166534"><?= h($success) ?></div>
<?php endif; ?>
<div class="card">
<div class="card-title">设置</div>
<form method="post" class="form">
<input type="hidden" name="mp_save" value="1">
<label>标题</label>
<input type="text" name="title" value="<?= h($title) ?>" maxlength="60">
<label style="margin-top:12px">
<input type="checkbox" name="enabled" value="1" <?= $enabled ? 'checked' : '' ?>>
启用
</label>
<div style="margin-top:16px">
<button class="btn" type="submit">保存</button>
</div>
</form>
</div>
<div class="card" style="margin-top:20px">
<div class="card-title">数据列表</div>
<?php if (!$items): ?>
<div class="empty">暂无数据</div>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead><tr><th>ID</th><th>名称</th><th>排序</th></tr></thead>
<tbody>
<?php foreach ($items as $it): ?>
<tr>
<td><?= (int)$it['id'] ?></td>
<td><?= h($it['name']) ?></td>
<td><?= (int)$it['sort_order'] ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<script src="<?= h(BLOG_URL . 'content/plugins/my_plugin/assets/admin.js') ?>?v=<?= (int)$jsVer ?>"></script>
A.4.8 assets/admin.css / admin.js / front.css / front.js
按业务需要填写即可。空文件也必须存在(否则 filemtime() 会 warning)。可填一行注释占位:
/* my_plugin admin styles */
/* my_plugin admin scripts */
A.5 exit / header / die 边界表
核心问题:哪些钩子里可以中断输出,哪些不能。
| 钩子 / 场景 | 能否 exit | 能否 header | 说明 |
|---|---|---|---|
| 主文件顶层 | ❌ | ❌ | 主文件被 include 时只能定义符号,不能有副作用 |
admin/register.php | ❌ | ❌ | 只做注册 |
activate.php / uninstall.php | ❌ | ❌ | 只做 DB 操作 |
渲染类钩子(index_head / admin_head / admin_footer / log_list_before / log_item_meta / log_display_after 等) | ❌ 红线 3 | ❌ | 布局已开始输出,中断毁整页 |
admin_request(每次后台请求) | ⚠️ 谨慎 | ⚠️ 谨慎 | 可拒绝非法访问,但要保证不会误伤正常请求 |
dispatch_start | ✅ 可 | ✅ 可 | 路由分发开始,尚未输出 |
public_action_xxx(?action=xxx) | ✅ 必须 | ✅ 必须 | 这是AJAX / 表单端点的标准位置,处理完必须 exit |
dispatch_plugin_route(老式插件路由) | ✅ 可 | ✅ 可 | 伪静态路由处理点 |
dispatch_end | ⚠️ 通常不需要 | ⚠️ | 分发结束,业务已完成 |
comment_save_before / comment_save_after | ❌ | ❌ | 数据层钩子,不涉输出 |
plugin_activate / plugin_deactivate / plugin_uninstall | ❌ | ❌ | 生命周期事件,只做记录 |
A.5.1 关键结论
- AJAX 端点必须走
public_action_xxx: - 触发点:URL 上带
?action=xxx - 位置:在任何 HTML 输出之前
- 写法:
header('Content-Type: application/json')→ 处理 →echo json_encode(...)→exit - 钩子注册:
Hook::addAction('public_action_xxx', 'MyHandleFunc')
- 渲染钩子里只能
echo追加内容,绝不能exit:任何exit会导致布局残缺 + 后续钩子失效。
A.6 AJAX 端点完整范例
抄这段代码即可完成「登录校验 + 参数校验 + 返回 JSON」的标准 AJAX 端点。
A.6.1 主文件注册钩子
if (class_exists('Hook')) {
try {
Hook::addAction('public_action_my_plugin_action', 'MP_handleAjax');
} 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
);
}
}
A.6.2 端点处理函数
if (!function_exists('MP_handleAjax')) {
function MP_handleAjax() {
// 1) 声明 JSON 输出(必须在任何 echo 之前)
header('Content-Type: application/json; charset=utf-8');
// 2) 登录校验(红线 8:AJAX 写操作第一件事)
if (empty($_SESSION['uid'])) {
echo json_encode(['ok' => false, 'msg' => '请先登录'], JSON_UNESCAPED_UNICODE);
exit;
}
$uid = (int)$_SESSION['uid'];
// 3) CSRF 校验(可选,若插件自行实现)
// $token = $_POST['_csrf'] ?? '';
// if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
// echo json_encode(['ok' => false, 'msg' => '非法请求'], JSON_UNESCAPED_UNICODE);
// exit;
// }
// 4) 方法校验
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'msg' => '方法不允许'], JSON_UNESCAPED_UNICODE);
exit;
}
// 5) 参数校验 + 强转
$itemId = (int)($_POST['item_id'] ?? 0);
$points = (int)($_POST['points'] ?? 0);
if ($itemId <= 0 || $points <= 0 || $points > MP_MAX_ITEMS) {
echo json_encode(['ok' => false, 'msg' => '参数错误'], JSON_UNESCAPED_UNICODE);
exit;
}
// 6) 业务处理(DB 操作必须 try/catch,异常不逃逸)
global $DB;
try {
// 6.1 存在性校验
$row = $DB->fetchOne(
"SELECT id, owner_id FROM `" . MP_table() . "` WHERE id = ?",
[$itemId]
);
if (!$row) {
echo json_encode(['ok' => false, 'msg' => '目标不存在'], JSON_UNESCAPED_UNICODE);
exit;
}
// 6.2 业务规则(防自操作、每日上限等)
if ((int)$row['owner_id'] === $uid) {
echo json_encode(['ok' => false, 'msg' => '不能对自己操作'], JSON_UNESCAPED_UNICODE);
exit;
}
// 6.3 事务内原子操作
$pdo = $DB->pdo();
$pdo->beginTransaction();
// 原子 UPDATE + rowCount 判定(红线 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']]
);
// 记录日志(插件自建表)
$DB->insert('my_plugin_logs', [
'user_id' => $uid,
'target_id' => $itemId,
'points' => $points,
'created_at' => time(),
]);
$pdo->commit();
echo json_encode([
'ok' => true,
'msg' => '成功',
'points' => $points,
], JSON_UNESCAPED_UNICODE);
exit;
} catch (Throwable $e) {
// 事务回滚(必须先 inTransaction 判断)
try {
if (isset($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;
}
}
}
A.6.3 前端调用示例
fetch('<?= BLOG_URL ?>?action=my_plugin_action', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
},
body: 'item_id=' + encodeURIComponent(id) + '&points=' + encodeURIComponent(points),
credentials: 'same-origin'
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.ok) { alert(res.msg); }
else { alert(res.msg || '操作失败'); }
})
.catch(function () { alert('网络错误'); });
A.6.4 AJAX 端点八条铁律
- 第一行
header('Content-Type: application/json; charset=utf-8'); - 登录校验(红线 8);
- 方法校验(
POSTonly 的写操作必须拒绝GET); - 参数强转 + 范围校验;
- DB 操作整段
try/catch; - 事务内先
inTransaction()再rollBack(); - 异常不逃逸(catch 里必须
echo json_encode(...)再exit); exit收尾(不 exit 页面继续渲染 layout,会污染 JSON)。
A.7 路径常量与斜杠约定
| 常量 | 值示例 | 结尾斜杠 | 用途 |
|---|---|---|---|
LITELOG_ROOT | /var/www/litelog/ | ✅ 带 | 文件系统根路径 |
CONTENT_PATH | /var/www/litelog/content/ | ✅ 带 | content 目录 |
ADMIN_PATH | /var/www/litelog/admin/ | ✅ 带 | admin 目录 |
INCLUDE_PATH | /var/www/litelog/include/ | ✅ 带 | include 目录 |
BLOG_URL | https://example.com/ | ✅ 带 | 站点根 URL |
DB_PREFIX | lg_ | ❌ 无 | 表名前缀 |
A.7.1 拼接规则
// ✅ 正确:常量结尾已带 /,不加前导斜杠
$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)
$url = BLOG_URL . '/content/plugins/...';
// ✅ 表名拼接:DB_PREFIX 无下划线,需自己加
$table = DB_PREFIX . 'my_plugin_items'; // lg_my_plugin_items
A.7.2 文件路径拼接
// 插件内引用自己的文件
__DIR__ . '/pages/settings.php' // 当前文件所在目录
__DIR__ . '/../assets/admin.css' // 上一级
// 引用 content 目录
CONTENT_PATH . 'plugins/my_plugin/xxx' // 推荐
CONTENT_PATH . 'backup/plugin-error.log' // 日志
A.8 命名约定:全局 vs 命名空间
A.8.1 结论
LiteLog 插件不使用 PHP 命名空间。 主文件里定义的类、函数、常量全部是全局的。
A.8.2 命名规范
| 符号 | 命名 | 示例 |
|---|---|---|
| 函数 | {前缀}_{CamelCase} | FLP_isEnabled()、Forum_routeIndex() |
| 常量 | {前缀}_UPPER | FLP_VER、FORUM_VER |
| 类 | {Prefix}Camel 或 {Prefix}_Camel | Widget_Forum、ForumBoard |
| 表名 | {DB_PREFIX}{插件名}_{表名} | lg_forum_thread |
| Option 键 | {插件名}_{键名} | forum_like_points_enabled |
A.8.3 前缀选取
- 短且唯一:3-6 个字母,全大写(函数/常量)或驼峰(类)。
- 优先取插件名的缩写:
like_points→FLP_或LikePoints_。 - 避免与核心冲突:核心已占用
Forum_*、UserGroup_*、Uc_*、Widget_*、HomepageMode_*。插件前缀不能重复这些。
A.8.4 示例
// ✅ 正确
function FLP_isEnabled() { ... }
define('FLP_VER', '1.0.0');
class Widget_Forum extends Widget { ... }
// ❌ 错误
function isEnabled() { ... } // 无前缀,可能与核心冲突
class Plugin { ... } // 通用名,核心可能已占用
namespace Plugin\LikePoints; // 不使用命名空间
A.9 视图可用变量清单
A.9.1 后台视图(admin/pages/*.php)
由 PluginRegistry::addAdminPage 的 callback include。视图里可以直接使用:
| 符号 | 说明 |
|---|---|
$DB | 全局 PDO 单例(需 global $DB;) |
Option | 配置类 |
h() / redirect() / dateFmt() / pagination() | 核心 helper |
BLOG_URL / CONTENT_PATH / DB_PREFIX | 常量 |
$_SESSION['uid'] / $_SESSION['role'] | 当前登录信息 |
$_GET['action'] | 当前 action |
$_GET['id'] / $_POST | 请求参数 |
核心 layout 会自动包裹视图:你在视图里输出的所有内容会被插入到 admin/views/layout.php 的 $content 位置。
不要在视图里:
include ADMIN_PATH . 'views/layout.php'(核心已包);- 输出完整的
<html>/<body>标签; - 使用
exit/die。
A.9.2 前台视图(views/*.php)
由 frontend/routes.php 的 callback include。可用符号与后台视图相同,但需注意:
- 后台 layout 的 CSS 类(
.card/.btn/.table)不自动加载; - 想复用主题布局,需主动调
View::display($name, $data)或直接输出 HTML; - 前台视图可用的主题变量依赖具体主题,无统一约定。
A.9.3 extract($data, EXTR_SKIP) 的变量注入
如果通过 View::display('log_list', ['logs' => $logs, ...]) 渲染,视图里可以直接用 $logs。
EXTR_SKIP 语义:如果 $data 里有 $DB 这样的键,不会覆盖已存在的全局 $DB。安全。
A.10 核心 helper 函数完整清单
所有函数在 include/lib/function.base.php / function.common.php 里定义,全局可用。
A.10.1 字符串 & 输出
| 函数 | 签名 | 说明 |
|---|---|---|
h($s) | h(mixed $s): string | htmlspecialchars($s, ENT_QUOTES, 'UTF-8') |
stripTags($s, $len = 0) | stripTags(string $s, int $len = 0): string | 去 HTML 标签,可选截断 |
getExcerpt($html, $len = 200) | getExcerpt(string $html, int $len = 200): string | 提取纯文本摘要 |
makeAlias($str) | makeAlias(string $str): string | 生成 URL 别名(拼音/短横线) |
A.10.2 时间
| 函数 | 签名 | 说明 |
|---|---|---|
dateFmt($ts) | dateFmt(int $ts): string | Y-m-d |
datetimeFmt($ts) | datetimeFmt(int $ts): string | Y-m-d H:i |
A.10.3 跳转 & 会话
| 函数 | 签名 | 说明 |
|---|---|---|
redirect($url) | redirect(string $url): void | 302 跳转 + exit(已自带 exit) |
isLoggedIn() | isLoggedIn(): bool | 是否登录 |
checkLogin() | checkLogin(): void | 强制登录(未登录跳登录页并 exit) |
A.10.4 分页 & 请求
| 函数 | 签名 | 说明 |
|---|---|---|
pagination($page, $total, $perPage) | pagination(int $page, int $total, int $perPage): string | 分页 HTML |
getPageNumber() | getPageNumber(): int | 当前页码(自动 clamp ≥ 1) |
pageUrl($page) | pageUrl(int $page): string | 生成保留当前 GET 参数的分页链接 |
A.10.5 URL 生成(Url 类)
Url::log($gid, $alias); // 文章链接
Url::sort($sid, $alias); // 分类链接
Url::tag($name); // 标签链接
Url::author($uid); // 作者链接
Url::record('2026-10'); // 归档链接
A.10.6 Option 类
Option::get($key, $default = ''); // string(★ 第二参数永远用 '',不能传 null)
Option::set($key, $value); // void
Option::delete($key); // void
A.10.7 核心模型
| 模型 | 常用方法 |
|---|---|
UserModel::getOne($uid) | 返回 ['uid', 'username', 'nickname', 'email', 'role', ...] 或 null |
UserModel::getByUsername($name) | 同 getOne |
LogModel::getOne($gid) | 文章详情 |
LogModel::latest($limit) | 最新文章列表 |
LogModel::addView($gid) | 浏览量 +1 |
SortModel::all() | 全部分类 |
SortModel::getOne($sid) | 单个分类 |
TagModel::getByName($name) | 按名查标签 |
CommentModel::byLog($gid) | 某文章的全部评论 |
CommentModel::add($data) | 插入评论 |
A.10.8 原生 PHP 函数(可直接用)
random_bytes($n)/bin2hex()—— 强随机(红线 4 要求)hash_equals($a, $b)—— 恒定时间字符串比较(CSRF token 校验)json_encode($x, JSON_UNESCAPED_UNICODE)/json_decode($s, true)mb_strlen($s, 'UTF-8')/mb_substr($s, 0, $n, 'UTF-8')ctype_digit($s)—— 严格判定纯数字字符串filter_var($v, FILTER_VALIDATE_URL)/FILTER_VALIDATE_EMAIL
A.10.9 不可用的函数(核心未提供)
- ❌
input()/request()/post()—— 直接读$_GET/$_POST - ❌
json_response()/api_response()—— 自己header+json_encode+exit - ❌
auth_user()/current_user()—— 用$_SESSION['uid'] - ❌
db()—— 用全局$DB - ❌
config()—— 用Option::get()或插件自己的XXX_conf()
A.11 快速自检清单(AI 交付前核对)
写完插件后,逐条打钩:
结构
- [ ] 目录名 = 主文件名(小写下划线)
- [ ] 有
{插件名}.php+activate.php+deactivate.php+uninstall.php - [ ] 有
admin/register.php+admin/pages/settings.php - [ ] 有
assets/目录(哪怕空文件)
安全
- [ ] 所有 SQL 用占位符(规则 2)
- [ ] 所有输出过
h()(规则 3) - [ ] 无文件操作,或文件操作过
basename()(规则 1) - [ ] AJAX 写操作有登录校验(红线 8)
- [ ] 原子扣减(红线 5)
- [ ] 无
unserialize/eval(红线 1)
500 免疫(v1.1 核心条款)
- [ ]
admin/register.php有class_exists('PluginRegistry')+try/catch(罪 1) - [ ] 主文件每个
define()有if (!defined(...))(罪 2) - [ ] 主文件每个
function xxx()有if (!function_exists(...))(罪 2) - [ ] 所有
Option::get()第二参数是'',无null(罪 3) - [ ]
activate.php整段try/catch,DDL 无COMMENT/COLLATE(罪 5) - [ ] 后台视图顶部有
function_exists()守卫(罪 6) - [ ] 所有
Hook::addAction()包在class_exists('Hook')+try/catch(罪 7)
API 使用(本附录)
- [ ]
$DB->fetchOne()判null而非false(A.2.4) - [ ]
$DB->delete()/update()判> 0而非直接if(A.2.4) - [ ] 用户名用
nickname ?: username(A.3.1) - [ ] 操作
user_profile.credit前先确保行存在(A.3.2 / A.3.8) - [ ] URL 拼接不重复加斜杠(A.7.1)
- [ ] 函数/常量/表名有插件前缀(A.8.2)
- [ ] AJAX 端点八条铁律全过(A.6.4)
验证
- [ ]
php -l每个.php文件都过 - [ ] 启用 → 停用 → 再启用 → 卸载 全流程无异常
- [ ] 后台菜单出现、页面能渲染、设置能保存
- [ ] 卸载后表删除、option 清空
A.12 附:本附录与规范正文的对应关系
| 本附录节 | 对应规范正文 |
|---|---|
| A.1 加载时机 | 补充 §三「插件目录结构」 |
A.2 $DB API | 补充 §八.1 |
| A.3 表字段 | 补充 §八.3 |
| A.4 最小骨架 | 补充 §十四「指令模板」 |
| A.5 exit 边界 | 补充 §二 红线 3 + §七.2 |
| A.6 AJAX 端点 | 补充 §七.2 public_action_xxx + §八.5 |
| A.7 路径常量 | 补充 §九.5 |
| A.8 命名约定 | 补充 §三 + §八.4 |
| A.9 视图变量 | 补充 §九.1 |
| A.10 helper 清单 | 补充 §九.4 |
| A.11 自检清单 | 汇总 §十三.2 + §十五.5 |
本附录为《LiteLog 插件开发规范》v1.1 的配套材料。若发现任何与实际内核不符之处,请以实际内核为准并更新本附录。