主站
论坛首页/ LiteLog /帖子

LiteLog 插件开发规范 · 附录 A:《API 参考》

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

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.5exit / header / die 边界表P1
A.6AJAX 端点完整范例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 各文件已可用符号表

文件触发时机已可用符号尚未可用
主文件 {插件名}.phpinit 后、路由前常量、$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、OptionHook 可能未加载;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;无 → 走 execPDOStatement可 ->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(用户主表)

列类型说明
uidINT UNSIGNED PK用户 ID(注意是 uid 不是 id)
usernameVARCHAR登录名
nicknameVARCHAR昵称(显示优先用 nickname ?: username)
emailVARCHAR邮箱
passwordVARCHAR密码 hash
statusTINYINT1=正常,0=禁用
roleVARCHAR'admin' 为管理员
reg_timeINT UNSIGNED注册时间戳

A.3.2 {DB_PREFIX}user_profile(用户资料扩展表)

列类型说明
uidINT UNSIGNED PK对应用户 ID
creditINT积分(点赞送分等场景操作此列)
avatarVARCHAR头像 URL
levelINT等级
bioTEXT简介
⚠️ 该表行可能不存在。写代码时先 SELECT COUNT(*) WHERE uid=?,为 0 则先 INSERT ... (uid, credit) VALUES (?, 0) 再操作。

A.3.3 {DB_PREFIX}blog(文章表)

列类型说明
gidINT UNSIGNED PK文章 ID(注意是 gid 不是 id)
titleVARCHAR标题
contentLONGTEXT正文(HTML)
aliasVARCHARURL 别名
sort_idINT分类 ID
uidINT作者 UID
dateINT UNSIGNED发布时间戳
viewsINT UNSIGNED浏览量
hideTINYINT1=隐藏

A.3.4 {DB_PREFIX}comment(评论表)

列类型说明
cidINT UNSIGNED PK评论 ID
gidINT所属文章 ID
pidINT父评论 ID(0 = 顶级)
uidINT评论者 UID(0 = 游客)
authorVARCHAR游客名
contentTEXT内容
dateINT UNSIGNED时间戳
statusTINYINT1=可见

A.3.5 {DB_PREFIX}option(配置表)

列类型说明
option_nameVARCHAR PK键名
option_valueTEXT值(永远是字符串)
⚠️ Option::get() 返回值永远是 string,不是 int / bool / array。enabled 存 '0'/'1' 字符串,判断用 === '1'。

A.3.6 {DB_PREFIX}sort / {DB_PREFIX}tag / {DB_PREFIX}tag_relation

表主键关键列
sortsidname / alias / sort_order
tagtidname
tag_relationidgid / tid

A.3.7 论坛插件表(若安装了 forum 插件)

表主键关键列
{前缀}forum_boardidname / description / sort / thread_count / post_count
{前缀}forum_threadidboard_id / uid / title / content / views / reply_count / is_top / is_essence / is_locked / highlight_color / last_reply_time / last_reply_uid / create_time
{前缀}forum_postidthread_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 端点八条铁律

  1. 第一行 header('Content-Type: application/json; charset=utf-8');
  2. 登录校验(红线 8);
  3. 方法校验(POST only 的写操作必须拒绝 GET);
  4. 参数强转 + 范围校验;
  5. DB 操作整段 try/catch;
  6. 事务内先 inTransaction() 再 rollBack();
  7. 异常不逃逸(catch 里必须 echo json_encode(...) 再 exit);
  8. 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_URLhttps://example.com/✅ 带站点根 URL
DB_PREFIXlg_❌ 无表名前缀

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()
常量{前缀}_UPPERFLP_VER、FORUM_VER
类{Prefix}Camel 或 {Prefix}_CamelWidget_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): stringhtmlspecialchars($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): stringY-m-d
datetimeFmt($ts)datetimeFmt(int $ts): stringY-m-d H:i

A.10.3 跳转 & 会话

函数签名说明
redirect($url)redirect(string $url): void302 跳转 + 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 的配套材料。若发现任何与实际内核不符之处,请以实际内核为准并更新本附录。

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