主站
论坛首页/ LiteLog /帖子

LiteLog 常用服务调用手册

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

LiteLog 常用服务调用手册

版本:1.1(2026-10-10) 定位:《LiteLog 插件开发规范》配套章节,说明编辑器 / 用户中心 / 用户组 / 货币系统四大服务的调用方式,供插件开发者集成。 给 AI 下指令时,如任务涉及编辑器 / 用户 / 权限 / 消息 / 签到 / 虚拟货币,把本手册一并附上。 所有 API 均按 LiteLog v1.0.0 内核 + user_group v2.0.0 + user_center v1.0.0 + currency v1.0.0 源码逐行核实。

目录

  1. 通用约定:跨插件调用的依赖检测
  2. 编辑器 LiteEditor 调用方法
  3. 用户中心 UserCenter API 调用方法
  4. 用户组 UserGroup API 调用方法
  5. 货币系统 Currency API 调用方法
  6. 综合示例:新插件同时使用四者
  7. API 快速索引

一、通用约定:跨插件调用的依赖检测

LiteLog 的插件机制是「启用即加载」:插件启用后,其主文件被 require_once 引入,主文件里定义的 *_函数 就是全局可用的。因此插件间调用只需要:

// 唯一需要的依赖检测方式
if (function_exists('Uc_addMessage')) {
    Uc_addMessage($uid, [...]);
}

1.1 三条铁律

  1. 调用前必须 function_exists() 检测——对方的插件可能未启用或被用户禁用。
  2. 调用必须 try/catch 兜底——对方的 API 可能内部异常,异常不能影响你自己的页面。
  3. 未启用时提供降级路径——不要让「A 插件没装」变成「B 插件崩溃」。

1.2 标准安全调用模板

/**
 * 安全调用外部插件服务
 *
 * @param string $fn      函数名
 * @param array  $args    参数列表
 * @param mixed  $default 依赖缺失/异常时的返回值
 * @return mixed
 */
function my_safe_call($fn, $args = [], $default = null) {
    if (!function_exists($fn)) return $default;
    try {
        return call_user_func_array($fn, $args);
    } catch (Throwable $e) {
        @file_put_contents(
            CONTENT_PATH . 'backup/plugin-error.log',
            '[' . date('Y-m-d H:i:s') . "] {$fn}\n" . $e->getMessage() . "\n\n",
            FILE_APPEND | LOCK_EX
        );
        return $default;
    }
}

// 用法
$groups = my_safe_call('UserGroup_getUserGroups', [$uid], []);
$unread = my_safe_call('Uc_countUnreadMessages', [$uid], 0);
$coin   = my_safe_call('currency_get_user_balance', [$uid, 'coin'], 0.0);

1.3 加载机制差异(重要)

服务加载方式调用前是否需要初始化
编辑器 LiteEditor核心内置(非插件),前端 JS 资产无,只需 <script> 引入
user_center主文件只注册钩子,API 在子文件需要:首次调用 Uc_* 前跑 Uc_boot()
user_group主文件一次性 require 全部子模块无需,直接调
currency主文件 require_once currency_api.php无需,直接调

user_center 的初始化模板:

if (function_exists('Uc_boot')) {
    try { Uc_boot(); } catch (Throwable $e) {}
}

1.4 未启用时的两种降级策略

宽松派(推荐用于可选功能):未启用 = 不限制 / 走降级实现。

严格派(用于强依赖):未启用 = 直接禁用入口 / 提示用户。

// 宽松:以权限检查为例
$canPost = true;                        // 默认放行
if (function_exists('UserGroup_checkPermission')) {
    try {
        $canPost = UserGroup_checkPermission($uid, 'forum_post');
    } catch (Throwable $e) { $canPost = true; }
}

// 严格:以消息通知为例
if (!function_exists('Uc_addMessage')) {
    echo '<p class="notice">本功能需要先启用「用户中心」插件。</p>';
    return;
}

二、编辑器 LiteEditor 调用方法

LiteEditor 是 LiteLog 核心内置的所见即所得 / Markdown 双模式编辑器,不是插件。核心文件:

文件作用全局导出
admin/assets/editor/editor.css编辑器样式—
admin/assets/editor/markdown.jsMarkdown 解析器window.LiteMarkdown
admin/assets/editor/editor.js编辑器主类window.LiteEditor

2.1 DOM 结构要求

LiteEditor 通过 querySelector 查找内部元素,DOM 结构必须严格按下面写(类名少一个都不行):

<div id="my-editor" class="lite-editor le-mode-wysiwyg">
  <!-- 工具栏:data-action / data-mode 按钮 -->
  <div class="le-toolbar">
    <button type="button" data-mode="wysiwyg">所见即所得</button>
    <button type="button" data-mode="markdown">Markdown</button>
    <button type="button" data-action="bold">B</button>
    <button type="button" data-action="italic">I</button>
    <button type="button" data-action="link">链接</button>
    <button type="button" data-action="image">图片</button>
    <!-- 更多按钮见 admin/views/log_edit.php -->
  </div>

  <!-- 主体:三个区缺一不可(CSS 会自动按模式显示/隐藏) -->
  <div class="le-body">
    <div class="le-wysiwyg" contenteditable="true"></div>
    <textarea class="le-textarea" placeholder="Markdown 源码..."></textarea>
    <div class="le-preview"></div>
  </div>

  <!-- 状态栏 -->
  <div class="le-footer">
    <span class="le-status"></span>
  </div>
</div>

三个必需类:.le-toolbar、.le-body(内含 .le-wysiwyg、.le-textarea、.le-preview)、.le-footer(内含 .le-status)。缺任何一个编辑器都会报错或静默失败。

2.2 工具栏 action 列表

分组data-action / data-mode
模式切换data-mode="wysiwyg" / data-mode="markdown"
标题h1 h2 h3
行内bold italic strike
对齐align-left align-center align-right align-justify
块级quote code codeblock
插入link image video table hr
列表ul ol task
工具undo redo search preview fullscreen help
保存save

按钮图标可以是文字(<b>B</b>)或 SVG(<svg>...</svg>),样式已由 editor.css 处理。

2.3 引入方式

<link rel="stylesheet" href="<?= BLOG_URL ?>admin/assets/editor/editor.css">
<script src="<?= BLOG_URL ?>admin/assets/editor/markdown.js"></script>
<script src="<?= BLOG_URL ?>admin/assets/editor/editor.js"></script>

顺序不能反:markdown.js 必须先于 editor.js(editor.js 内部会 new LiteMarkdown(...))。

2.4 初始化

var editor = new LiteEditor('#my-editor', {
    uploadUrl:        BLOG_URL + 'admin/upload.php',  // 图片上传接口
    placeholder:      '开始写作...',
    minHeight:        480,
    autoSaveKey:      'my_plugin_draft',              // 每个编辑器用不同 key,避免冲突
    autoSaveInterval: 30000,                          // 30 秒
    smartPaste:       true,                           // 智能粘贴(HTML→MD、代码块识别)
    defaultMode:      'wysiwyg',                      // 'wysiwyg' | 'markdown'
    onSave:           null,                           // Ctrl+S 回调
});

参数详解:

参数类型默认值说明
uploadUrlstring''图片上传接口 URL。留空会导致上传功能失效(弹"未配置上传地址")。后端必须返回 {ok:true, url:'...'}。
placeholderstring'开始写作...'占位提示,支持 \n 换行
minHeightnumber480编辑区最小高度(px)
autoSaveKeystring'litelog_draft'localStorage 键名。多编辑器场景务必用不同 key,否则草稿互相覆盖。
autoSaveIntervalnumber30000自动保存间隔(毫秒)
smartPastebooltrue是否智能处理粘贴(HTML 转 MD、代码识别)
defaultModestring'wysiwyg'初始模式
onSavefunction\nullnull

2.5 公开 API(5 个方法)

方法返回值说明
getMarkdown()string获取当前内容的 Markdown 源码。表单提交时应保存这个。
getHtml()string获取渲染后的 HTML(Markdown 先解析成 HTML)
getValue()stringgetMarkdown() 的别名
setValue(md)void设置内容,参数是 Markdown 字符串
triggerSave()void手动触发 onSave 回调

转换关系:

用户输入(所见即所得模式)
  ↓ getMarkdown()
Markdown 源码 → 存库 content_md 字段
  ↓ getHtml()
渲染后的 HTML → 存库 content 字段
  ↓ setValue(md)
反向回填(编辑历史内容)

2.6 完整示例:插件后台嵌入编辑器

假设你要写一个 note 插件,让管理员写笔记:

<?php
// content/plugins/note/admin/pages/edit.php
$note = /* 从数据库读 $note,含 content_md 字段 */;
?>

<!-- 引入编辑器资产 -->
<link rel="stylesheet" href="<?= BLOG_URL ?>admin/assets/editor/editor.css">
<script src="<?= BLOG_URL ?>admin/assets/editor/markdown.js"></script>
<script src="<?= BLOG_URL ?>admin/assets/editor/editor.js"></script>

<form id="noteForm" method="post">
    <input type="text" name="title" value="<?= h($note['title'] ?? '') ?>"
           class="title-input" placeholder="笔记标题..." required>

    <div id="note-editor" class="lite-editor le-mode-wysiwyg">
      <div class="le-toolbar">
        <button type="button" data-mode="wysiwyg">所见即所得</button>
        <button type="button" data-mode="markdown">Markdown</button>
        <span class="le-tb-sep"></span>
        <button type="button" data-action="bold"><b>B</b></button>
        <button type="button" data-action="italic"><i>I</i></button>
        <button type="button" data-action="code">&lt;/&gt;</button>
        <button type="button" data-action="link">链接</button>
        <button type="button" data-action="image">图片</button>
        <button type="button" data-action="ul">列表</button>
        <span class="le-spacer"></span>
        <button type="button" data-action="fullscreen">全屏</button>
      </div>
      <div class="le-body">
        <div class="le-wysiwyg" contenteditable="true"></div>
        <textarea class="le-textarea"><?= h($note['content_md'] ?? '') ?></textarea>
        <div class="le-preview"></div>
      </div>
      <div class="le-footer"><span class="le-status"></span></div>
    </div>

    <!-- 表单提交时把 Markdown / HTML 写入隐藏域 -->
    <input type="hidden" name="content_md"   id="hidden-content-md">
    <input type="hidden" name="content_html" id="hidden-content-html">

    <button type="submit" class="btn">保存笔记</button>
</form>

<script>
(function () {
    var editor = new LiteEditor('#note-editor', {
        uploadUrl:   '<?= BLOG_URL ?>admin/upload.php',
        placeholder: '记录你的想法...',
        autoSaveKey: 'note_plugin_draft',   // 独一无二,避免与其他编辑器冲突
        defaultMode: 'wysiwyg',
    });

    document.getElementById('noteForm').addEventListener('submit', function () {
        document.getElementById('hidden-content-md').value   = editor.getMarkdown();
        document.getElementById('hidden-content-html').value = editor.getHtml();
    });
})();
</script>

后端接收:

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $title       = trim($_POST['title'] ?? '');
    $contentMd   = $_POST['content_md']   ?? '';
    $contentHtml = $_POST['content_html'] ?? '';

    // 存库时两个都存:md 给编辑器回填,html 给前台渲染
    global $DB;
    $DB->insert('note', [
        'title'        => $title,
        'content_md'   => $contentMd,
        'content_html' => $contentHtml,
        'created_at'   => time(),
    ]);
}

2.7 常见坑

症状原因解决
工具栏贴不到顶部 / 布局乱外层容器设了 overflow: hidden容器必须 overflow: visible(editor.css 注释已说明)
图片上传报"未配置上传地址"uploadUrl 没传传 BLOG_URL . 'admin/upload.php'
两个编辑器草稿互串都用默认 autoSaveKey每个编辑器传不同 key
Ctrl+S 不触发保存没传 onSave传一个提交表单的回调
报 LiteMarkdown is not definedscript 顺序反了先引 markdown.js
表格 / 图片在编辑区不显示忘了 .le-body 三件套之一三个区必须都存在
全屏模式下工具栏消失外层是 position: fixed 且未加 .le-fullscreen 类让 editor.css 的 .le-fullscreen 规则生效,不要覆盖

三、用户中心 UserCenter API 调用方法

来源插件:content/plugins/user_center/(标识 user_center)
前缀:Uc_

加载机制:user_center 采用懒加载机制。主文件 user_center.php 只做钩子注册,真正的 API 在 user_center_api.php。**首次调用任何 Uc_* 函数前,需要先确保 Uc_boot() 已执行**。

3.1 依赖检测

/**
 * 判断用户中心插件是否可用(含初始化)
 */
function my_uc_available() {
    if (!function_exists('Uc_boot')) return false;
    try {
        Uc_boot();
        return function_exists('Uc_addMessage');
    } catch (Throwable $e) {
        return false;
    }
}

Uc_boot() 内部会:

  1. require user_center_api.php / user_center_page.php / user_center_forum.php
  2. 检测数据表版本,必要时执行建表

Uc_boot() 是幂等的,多次调用只执行一次。所以可以放心在每个入口函数开头都调一次。

3.2 数据表

user_center 会创建 5 张表:

表名用途
{前缀}uc_message消息通知
{前缀}uc_follow关注关系
{前缀}uc_signin签到记录
{前缀}uc_favorite文章收藏
{前缀}uc_login_log登录日志

注意:user_center 会尝试给核心 comment 表补 uid 列(在 uc_buildTables() 里 ALTER TABLE)。如果 comment 表没有 uid 列,某些 API(如 Uc_getUserComments)会自动降级返回空。

3.3 消息 API

函数说明返回
Uc_addMessage($uid, $data)给用户发消息新消息 ID
Uc_getMessages($uid, $page, $perPage, $onlyUnread, &$total)分页获取消息数组
Uc_countUnreadMessages($uid)未读数int
Uc_markMessageRead($uid, $id)标记已读void
Uc_markAllMessagesRead($uid)全部已读void
Uc_deleteMessage($uid, $id)删除消息void

Uc_addMessage 参数详解:

Uc_addMessage($uid, [
    'from_uid' => 0,                          // 发送者 UID(系统消息填 0)
    'type'     => 'system',                   // system / follow / comment / reply / like
    'title'    => '你的文章被收藏了',           // 标题(必填)
    'content'  => '用户 XXX 收藏了你的文章',    // 正文(可选)
    'link'     => BLOG_URL . '?post=123',     // 点击跳转链接(可选)
]);

type 支持的值(用于前端图标选择,未知 type 会降级为默认图标):

type含义
system系统消息
follow关注提醒
comment评论通知
reply回复通知
like点赞通知

典型用法:文章被评论时通知作者

Hook::addAction('comment_save_after', function ($cid, $data) {
    if (!function_exists('Uc_boot')) return;

    try { Uc_boot(); } catch (Throwable $e) { return; }
    if (!function_exists('Uc_addMessage')) return;

    // 找到文章作者
    global $DB;
    $author = (int)$DB->fetchColumn(
        "SELECT author FROM " . DB_PREFIX . "blog WHERE gid=?",
        [(int)$data['gid']]
    );
    if ($author <= 0) return;

    // 不给自己发
    if ($author === (int)($data['uid'] ?? 0)) return;

    try {
        Uc_addMessage($author, [
            'from_uid' => (int)($data['uid'] ?? 0),
            'type'     => 'comment',
            'title'    => '你的文章有新评论',
            'content'  => mb_substr(strip_tags($data['comment']), 0, 80, 'UTF-8'),
            'link'     => BLOG_URL . '?post=' . (int)$data['gid'] . '#comment-' . (int)$cid,
        ]);
    } catch (Throwable $e) {}
});

3.4 关注 API

函数说明
Uc_follow($uid, $target)关注(自动给目标发通知)
Uc_unfollow($uid, $target)取关
Uc_isFollowing($uid, $target)是否已关注 → bool
Uc_getFollowing($uid, $page, $perPage, &$total)关注列表
Uc_getFollowers($uid, $page, $perPage, &$total)粉丝列表

注意:

  • Uc_follow 内部已做「自己不能关注自己」「已关注不重复插入」判断,无需外部校验。
  • Uc_follow 成功时会给目标用户发一条 follow 类型消息。如果你不希望发通知,请不要用 Uc_follow,直接 INSERT uc_follow(但不推荐,绕过业务规则)。
  • 返回的关注/粉丝列表项包含 uid、username、nickname、create_time。

3.5 签到 API

函数说明
Uc_signin($uid)执行签到,返回数组
Uc_hasSignedToday($uid)今日是否已签 → bool
Uc_getSigninCalendar($uid, $year, $month)月历数据:['2026-10-01' => ['days'=>3, 'credit'=>8], ...]
Uc_getSigninStats($uid)['total'=>N, 'max_days'=>N, 'total_credit'=>N, 'current'=>N]

Uc_signin 返回结构:

// 成功
['ok' => true, 'days' => 3, 'credit' => 8, 'msg' => '签到成功!连续 3 天,获得 8 积分']

// 失败(今天已签 / 数据库错误)
['ok' => false, 'msg' => '今天已经签到过了']

积分奖励规则(可在后台设置):

设置项默认值含义
uc_signin_credit5基础积分
uc_signin_continue3连续加成

实际计算:

  • 连续 ≥ 3 天:基础 + 1 × 加成
  • 连续 ≥ 7 天:基础 + 2 × 加成
  • 其他:只有基础

签到积分写入位置:user_center 内部通过 Uc_refreshCredit() 修改 user_profile.credit 字段,不涉及 currency 插件。

3.6 收藏 API

函数说明
Uc_favorite($uid, $gid)收藏文章
Uc_unfavorite($uid, $gid)取消收藏
Uc_isFavorited($uid, $gid)是否已收藏 → bool
Uc_getFavorites($uid, $page, $perPage, &$total)分页列表(含文章标题/封面/别名/浏览数/评论数)

3.7 用户资料 API(与 user_group 共享)

user_center 不维护自己的资料表,而是委托给 user_group:

函数说明user_group 未启用时
Uc_getProfile($uid)读资料返回默认空结构
Uc_updateProfile($uid, $data)写资料返回 false
Uc_getUserGroups($uid)用户组列表返回 []
Uc_refreshCredit($uid, $delta)修改积分静默失败
Uc_userAvatar($uid, $size)返回头像 HTML用首字母+随机渐变
建议:涉及资料读写,优先直接调 user_group 的同名函数,比 Uc_* 更明确。user_center 的 Uc_getProfile 只是给「不想强依赖 user_group」的场景兜底的。

3.8 统计与工具 API

函数说明
Uc_getUserStats($uid)返回 ['posts'=>N, 'comments'=>N, 'following'=>N, 'followers'=>N, 'favorites'=>N, 'unread'=>N]
Uc_timeAgo($ts)时间戳 → "3 分钟前" / "2 天前" / "2026-10-01"
Uc_currentUid()当前登录 UID(未登录返回 0)
Uc_userAvatar($uid, $size)生成头像 HTML(有图用图,无图用首字母渐变)
Uc_getUserComments($uid, $page, $perPage, &$total)我的评论(依赖 comment 表有 uid 列)

Uc_userAvatar 实现细节:8 组预设渐变(靛蓝/青/翠绿/琥珀/粉/紫/天蓝/玫红),按 uid % 8 分配,同一用户永远同一套色。

3.9 论坛集成 API(可选)

只有 forum 插件启用时可用(Uc_forumAvailable() 检测)。

函数说明
Uc_forumAvailable()论坛是否可用 → bool
Uc_getForumStats($uid)['threads'=>N, 'replies'=>N, 'received'=>N]
Uc_getForumThreads($uid, $page, $perPage, &$total)我的帖子
Uc_getForumReplies($uid, $page, $perPage, &$total)我的回复
Uc_getReceivedReplies($uid, $limit)帖子收到的新回复
Uc_forumUrl($sub = '', $params = [])生成论坛 URL

3.10 完整调用示例:帖子详情页加"关注作者"

<?php
// 前台某处
$currentUid = (int)($_SESSION['uid'] ?? 0);
$authorUid  = (int)$thread['uid'];

// 依赖检测
$ucReady = function_exists('Uc_boot');
if ($ucReady) { try { Uc_boot(); } catch (Throwable $e) {} }

if ($ucReady && function_exists('Uc_isFollowing') && $currentUid > 0 && $currentUid !== $authorUid):
    $following = false;
    try { $following = Uc_isFollowing($currentUid, $authorUid); } catch (Throwable $e) {}
?>
    <form method="post" action="<?= BLOG_URL ?>?action=uc_follow_toggle" style="display:inline">
        <input type="hidden" name="target_uid" value="<?= $authorUid ?>">
        <input type="hidden" name="back" value="<?= h($_SERVER['REQUEST_URI']) ?>">
        <button class="btn <?= $following ? 'btn-outline' : '' ?>">
            <?= $following ? '已关注' : '+ 关注' ?>
        </button>
    </form>
<?php endif; ?>

未启用 user_center 时:$ucReady = false,按钮整块不渲染 —— 这就是"优雅降级"。


四、用户组 UserGroup API 调用方法

来源插件:content/plugins/user_group/(标识 user_group)
前缀:UserGroup_

加载机制:user_group 主文件每次请求都会 require 全部子模块(api/admin/frontend),所以不需要 _boot,直接 function_exists 检测即可。

4.1 依赖检测

function my_ug_available() {
    return function_exists('UserGroup_getUserGroups');
}

4.2 数据表

表名用途关键字段
{前缀}user_group用户组定义id, name, slug, color, sort, permissions(JSON), user_count
{前缀}user_group_map用户-组映射uid, group_id(唯一键)
{前缀}user_profile用户资料uid, avatar, bio, gender, credit, experience, level, status, ...

4.3 用户组读取

函数说明
UserGroup_getGroups()所有用户组(按 sort 升序)
UserGroup_getGroup($id)单个用户组
UserGroup_getUserGroups($uid)用户所属的组(对象数组)
UserGroup_getUserGroupIds($uid)用户所属组 ID 数组
UserGroup_getUserGroupNames($uid)用户所属组名数组

返回结构:

UserGroup_getUserGroups($uid);
// [
//   ['id'=>1, 'name'=>'普通用户', 'slug'=>'member', 'color'=>'#6b7280',
//    'permissions'=>'{"forum_post":1,...}', 'sort'=>0, 'user_count'=>128, ...],
//   ['id'=>2, 'name'=>'VIP 会员', 'slug'=>'vip', 'color'=>'#f59e0b', ...],
// ]

4.4 用户组写入

UserGroup_setUserGroups($uid, [1, 3, 5]);
// 完全覆盖式写入:先清空该用户所有组,再插入新组
// 会自动刷新所有用户组的人数统计

注意:这是全量覆盖,不是追加。追加场景要自己先 getUserGroupIds 再合并:

$current = UserGroup_getUserGroupIds($uid);
$new = array_unique(array_merge($current, [5]));
UserGroup_setUserGroups($uid, $new);

4.5 权限检查

函数说明
UserGroup_checkPermission($uid, $perm)管理员直通($_SESSION['role']==='admin' 直接 true),否则查用户组权限并集
UserGroup_hasPermission($uid, $perm)只查用户组权限,不做管理员直通
UserGroup_isAdmin()当前登录用户是否管理员
UserGroup_requireAdmin()强制管理员,否则 403 退出

权限点命名(user_group 内置定义的):

权限点含义
forum_post论坛发帖
forum_reply论坛回复
forum_attach上传附件
forum_manage论坛管理
blog_comment博客评论

扩展自定义权限点:permissions 字段是自由的 JSON,你可以在自己的插件里定义新权限点。但管理员在后台编辑用户组时看不到——除非你把权限点注入到 user_group 的编辑表单里。目前 user_group 未提供权限注册钩子,新权限点需要用户组管理员手填 JSON。

4.6 用户资料

函数说明
UserGroup_getProfile($uid)读资料(不存在自动补建空记录)
UserGroup_updateProfile($uid, $data)写资料($data 是键值数组)
UserGroup_getUserFull($uid)组合:user + profile + groups(不含 password)
UserGroup_recordLogin($uid)记录登录时间和 IP

资料字段(user_profile 表):

uid, avatar, bio, gender, birthday, location, website, qq, wechat,
credit, experience, level, status, reg_ip, reg_time,
last_login_time, last_login_ip, update_time

gender 值:0 保密 / 1 男 / 2 女
status 值:1 正常 / 0 禁用

4.7 展示组件

函数说明
UserGroup_renderBadge($uid)直接返回用户组标签 HTML(多个组会并列显示)
UserGroup_userAvatar($uid, $size)直接返回头像 HTML(有图用图,无图首字母+随机渐变)

两函数输出已自带转义,可直接 echo,不用再 h()。

典型用法:

<!-- 文章作者旁显示用户组标签 -->
<div class="post-author">
    <?= h($authorName) ?>
    <?= UserGroup_renderBadge($authorId) ?>
</div>

<!-- 显示头像 -->
<div class="avatar">
    <?= UserGroup_userAvatar($uid, 48) ?>
</div>

4.8 当前登录用户

函数说明
UserGroup_currentUid()当前 UID(未登录返回 0)
UserGroup_currentUser()当前用户完整信息(未登录返回 null)

4.9 用户列表查询(后台)

$total = null;
$users = UserGroup_queryUsers($page, $perPage, $kw, $status, $groupId, $total);
// $kw      关键词(用户名/昵称/邮箱,空字符串不过滤)
// $status  '' 全部 / 1 正常 / 0 禁用
// $groupId 0 全部 / 具体组 ID
// $total   引用出参,返回总数

返回行结构:

['uid', 'username', 'nickname', 'email', 'role', 'status', 'credit',
 'level', 'avatar', 'last_login_time', 'reg_time']

4.10 完整调用示例:帖子详情页加"发帖权限"判断

<?php
$currentUid = (int)($_SESSION['uid'] ?? 0);

// 宽松派:未装 user_group 时默认放行
$canPost = true;
if ($currentUid > 0 && function_exists('UserGroup_checkPermission')) {
    try {
        $canPost = UserGroup_checkPermission($currentUid, 'forum_post');
    } catch (Throwable $e) { $canPost = true; }
}

if ($canPost):
?>
    <a class="btn" href="<?= BLOG_URL ?>?action=forum_new">发帖</a>
<?php else: ?>
    <span class="btn-disabled">你没有发帖权限</span>
<?php endif; ?>

选择宽松还是严格:

场景建议
社区发帖/评论宽松(未装权限插件就不限制)
后台管理入口严格(未装权限插件就禁止)
付费功能严格(未装权限插件就不放行)

五、货币系统 Currency API 调用方法

来源插件:content/plugins/currency/(标识 currency)
前缀:currency_
加载机制:主文件 currency.php 每次请求都会 require_once currency_api.php,所以无需 _boot,只需 function_exists 检测。

5.1 依赖检测

function my_currency_available() {
    return function_exists('currency_change_balance')
        && function_exists('currency_get_user_balance');
}

5.2 数据表

表名用途关键字段
{前缀}currency货币类型定义id, name, key, icon, decimals, exchange_rate, status
{前缀}currency_user用户钱包余额uid, currency_key, balance, updated_at(唯一键 uid + currency_key)
{前缀}currency_log余额变动流水uid, currency_key, amount, balance_after, action, description, related_id, created_at

currency.key 是货币的业务唯一标识(如 coin、ticket_coin、credit),所有 API 都用 key 不用 id。默认初始化一个 key='coin' 的「金币」。

5.3 读取类 API

currency_get_types() — 所有启用的货币

$types = currency_get_types();
// [
//   ['id'=>1, 'name'=>'金币', 'key'=>'coin', 'icon'=>'<svg...>',
//    'decimals'=>2, 'exchange_rate'=>100, 'status'=>1, ...],
// ]

只返回 status=1 的货币;已停用的货币不会出现在结果里,但已有余额仍然可以被读取和操作。

currency_get_user_balance($uid, $currency_key = null) — 用户余额

// 单个货币
$coin = currency_get_user_balance($uid, 'coin');        // → 100.00(float)

// 所有货币(返回 key => balance 映射)
$all = currency_get_user_balance($uid);
// ['coin' => 100.00, 'ticket_coin' => 5.00]

注意:用户从未获得过某货币时,返回 0.00(不会自动建档)。

5.4 核心写 API:currency_change_balance()

这是唯一应该用来加减余额的入口。 它的实现保证了四个关键点:事务 + 行锁 + 流水记录 + 钩子广播。

currency_change_balance(
    $uid,           // int    用户 ID
    $currency_key,  // string 货币 key(如 'coin')
    $amount,        // float  变动量,正数加、负数扣
    $action,        // string 业务标识,见下表
    $description,   // string 描述(会显示在后台流水里)
    $related_id     // int    关联业务 ID(订单号/帖子 ID/评论 ID)
);
// 返回:成功 → 变动后余额(float);失败 → false

返回值必须判断:

$result = currency_change_balance($uid, 'coin', -10, 'purchase', '购买道具', $itemId);
if ($result === false) {
    // 货币未启用 / 事务失败 / 数据库错误
    return ['ok' => false, 'msg' => '扣款失败'];
}
$newBalance = $result;

⚠️ 必须用 $result === false 判断,不要用 !$result —— 当余额恰好变成 0.0 时,!0.0 为 true,会误判为失败。

$action 命名约定(后台流水会按它分类展示):

action场景
admin_adjust管理员手动调整(默认值)
reward系统奖励(发帖奖励、签到奖励)
purchase消费/购买
refund退款
transfer_in / transfer_out转账收入/支出
signin签到奖励
task任务奖励
自定义前缀建议用 {插件名}_{动作} 避免与其他插件冲突,如 topic_reward_escrow

小数位自动处理:函数内部会按货币定义里的 decimals 字段四舍五入,你传 -3.14159、货币是 2 位小数时,实际扣 -3.14。

允许余额为负:代码注释明确写了「系统可以设置不允许余额为负,这里默认允许」。如果业务要求禁止负数,调用方必须先查后扣:

// 严格禁止负余额:先查余额
$balance = currency_get_user_balance($uid, 'coin');
if ($balance < $cost) {
    return ['ok' => false, 'msg' => '余额不足'];
}
$result = currency_change_balance($uid, 'coin', -$cost, 'purchase', '购买道具', $itemId);
⚠️ 并发隐患提示:这种"先查后扣"在秒杀/并发场景下可能超扣。若你的业务是高频并发(如商城限时抢购),建议不要直接用这个 API 做扣减,自己在插件里对 currency_user 表加 FOR UPDATE 重写扣减逻辑,或者参考《插件开发规范》红线 5 的原子 UPDATE 模式:
// 并发安全的原子扣减(自行实现,不走 currency_change_balance)
$DB->pdo()->beginTransaction();
try {
    $stmt = $DB->query(
        "UPDATE `" . DB_PREFIX . "currency_user`
         SET balance = balance - ?, updated_at = ?
         WHERE uid = ? AND currency_key = ? AND balance >= ?",
        [$cost, time(), $uid, $key, $cost]
    );
    if ($stmt->rowCount() === 0) {
        $DB->pdo()->rollBack();
        return ['ok' => false, 'msg' => '余额不足'];
    }
    // ... 写流水、写业务表
    $DB->pdo()->commit();
} catch (Throwable $e) {
    if ($DB->pdo()->inTransaction()) $DB->pdo()->rollBack();
}

5.5 钩子:currency_balance_changed

任何时候余额变动都会触发,是其他插件监听货币活动的最佳切入点。

Hook::addAction('currency_balance_changed', function (
    $uid, $currency_key, $amount, $newBalance, $action, $description, $related_id
) {
    // 示例:单笔交易 > 10000 时记录审计日志
    if (abs($amount) >= 10000) {
        @file_put_contents(
            CONTENT_PATH . 'backup/currency-audit.log',
            sprintf("[%s] uid=%d key=%s amount=%s after=%s action=%s\n",
                date('Y-m-d H:i:s'), $uid, $currency_key, $amount, $newBalance, $action),
            FILE_APPEND | LOCK_EX
        );
    }

    // 示例:把大额消费推给 user_center 发消息
    if ($action === 'purchase' && $amount < -100) {
        if (function_exists('Uc_boot')) {
            try {
                Uc_boot();
                if (function_exists('Uc_addMessage')) {
                    Uc_addMessage($uid, [
                        'type'    => 'system',
                        'title'   => '大额消费提醒',
                        'content' => "你刚刚消费了 " . abs($amount) . " {$currency_key}",
                    ]);
                }
            } catch (Throwable $e) {}
        }
    }
}, 10);

钩子触发时机:在事务 commit() 之后。如果监听方抛异常,不会回滚余额变动,只会被 Hook 系统 catch 吞掉。所以监听方必须自己 try/catch。

钩子参数说明:

参数类型说明
$uidint用户 ID
$currency_keystring货币 key
$amountfloat变动量(正负)
$newBalancefloat变动后余额
$actionstring业务标识
$descriptionstring描述
$related_idint关联业务 ID

5.6 后台入口

URL页面
?action=currency货币列表
?action=currency&sub=list货币列表
?action=currency&sub=users用户余额(调整余额)
?action=currency&sub=logs流水记录

后台菜单注入方式:currency 通过 Hook::addAction('admin_menu', ...) 硬编码注入,不经过 PluginRegistry(这是旧写法,新插件应参考《插件开发规范》第一节走 PluginRegistry::addAdminMenu)。

5.7 完整调用示例:悬赏插件集成货币

场景:topic_reward 插件发布悬赏时从发布者钱包扣币,领取时给对方加币。

<?php
// ---------- 发布悬赏 ----------
public static function publish($gid, $ownerUid, $points) {
    // 1. 依赖检测
    if (!function_exists('currency_change_balance')) {
        return ['ok' => false, 'msg' => '货币系统未启用,无法发布悬赏'];
    }

    // 2. 余额校验
    $balance = currency_get_user_balance($ownerUid, 'coin');
    if ($balance < $points) {
        return ['ok' => false, 'msg' => '金币不足,需要 ' . $points . ',当前 ' . $balance];
    }

    // 3. 扣款(失败立即返回)
    $after = currency_change_balance(
        $ownerUid,
        'coin',
        -$points,
        'topic_reward_escrow',      // 自定义 action,前缀插件名
        '发布悬赏:帖子 #' . $gid,
        $gid
    );
    if ($after === false) {
        return ['ok' => false, 'msg' => '扣款失败,请稍后再试'];
    }

    // 4. 写入悬赏记录
    global $DB;
    try {
        $DB->insert('topic_reward', [
            'gid'         => $gid,
            'owner_uid'   => $ownerUid,
            'points'      => $points,
            'total'       => 1,
            'remaining'   => 1,
            'status'      => 'open',
            'create_time' => time(),
        ]);
    } catch (Throwable $e) {
        // ★ 业务表写入失败 → 必须退款,否则钱没了
        currency_change_balance(
            $ownerUid, 'coin', $points,
            'topic_reward_refund',
            '悬赏写入失败自动退款',
            $gid
        );
        return ['ok' => false, 'msg' => '发布失败,已自动退款'];
    }

    return ['ok' => true, 'msg' => '悬赏已发布'];
}

// ---------- 领取悬赏 ----------
public static function claim($rewardId, $claimUid) {
    if (!function_exists('currency_change_balance')) {
        return ['ok' => false, 'msg' => '货币系统未启用'];
    }

    global $DB;
    $reward = $DB->fetchOne(
        "SELECT * FROM `" . DB_PREFIX . "topic_reward` WHERE id=? AND status='open'",
        [(int)$rewardId]
    );
    if (!$reward) return ['ok' => false, 'msg' => '悬赏不存在'];
    if ((int)$reward['owner_uid'] === $claimUid) {
        return ['ok' => false, 'msg' => '不能领自己的悬赏'];
    }

    // 原子扣减剩余份额(红线 5)
    $DB->pdo()->beginTransaction();
    try {
        $stmt = $DB->query(
            "UPDATE `" . DB_PREFIX . "topic_reward`
             SET remaining = remaining - 1
             WHERE id=? AND remaining > 0 AND status='open'",
            [(int)$rewardId]
        );
        if ($stmt->rowCount() === 0) {
            $DB->pdo()->rollBack();
            return ['ok' => false, 'msg' => '已被领完'];
        }
        $DB->insert('topic_reward_claim', [
            'reward_id'  => (int)$rewardId,
            'uid'        => $claimUid,
            'points'     => (int)$reward['points'],
            'claim_time' => time(),
        ]);
        $DB->pdo()->commit();
    } catch (Throwable $e) {
        if ($DB->pdo()->inTransaction()) $DB->pdo()->rollBack();
        return ['ok' => false, 'msg' => '领取失败'];
    }

    // 加币给领取人
    currency_change_balance(
        $claimUid, 'coin', (int)$reward['points'],
        'topic_reward_claim',
        '领取悬赏:帖子 #' . $reward['gid'],
        $rewardId
    );

    return ['ok' => true, 'msg' => '领取成功'];
}

关键点:

  1. 扣款先,业务表写后——钱扣掉了但业务表写失败时可以补退;
  2. 自定义 action 用 插件名_动作 前缀,避免和其他插件流水混淆;
  3. related_id 传业务 ID(帖子 ID、订单 ID),便于事后对账;
  4. $after === false 是唯一的失败判定,别用 !$after。

5.8 与其他插件联动示例

5.8.1 与 user_center:余额变动通知

Hook::addAction('currency_balance_changed', function ($uid, $key, $amount, $after, $action, $desc) {
    if (!function_exists('Uc_boot')) return;
    try { Uc_boot(); } catch (Throwable $e) { return; }
    if (!function_exists('Uc_addMessage')) return;

    // 只在扣款时通知,充值/奖励不打扰
    if ($amount >= 0) return;

    try {
        Uc_addMessage($uid, [
            'type'    => 'system',
            'title'   => '账户扣款通知',
            'content' => "扣款 " . abs($amount) . " {$key}:{$desc},当前余额 {$after}",
            'link'    => BLOG_URL . '?action=uc_dashboard',
        ]);
    } catch (Throwable $e) {}
});

5.8.2 与 user_group:以用户组决定汇率

function my_get_effective_rate($uid, $currencyKey) {
    global $DB;
    $base = (float)$DB->fetchColumn(
        "SELECT exchange_rate FROM " . DB_PREFIX . "currency WHERE `key`=? AND status=1",
        [$currencyKey]
    );
    if ($base <= 0) return 1.0;

    // VIP 用户买到更多(假设 user_group 里有 slug='vip' 的组)
    if (function_exists('UserGroup_getUserGroups')) {
        try {
            foreach (UserGroup_getUserGroups($uid) as $g) {
                if ($g['slug'] === 'vip') return $base * 1.2;
            }
        } catch (Throwable $e) {}
    }
    return $base;
}

5.9 常见坑

症状原因解决
Call to undefined function currency_change_balance()未启用 currency 插件调用前 function_exists() 检测
扣了钱但余额没变beginTransaction 后表不是 InnoDB用 SHOW TABLE STATUS 确认引擎是 InnoDB;MyISAM 不支持行锁和回滚
返回 false 但看不到错误currency 内部 catch 里只 rollBack 没记日志建议改你的调用点——在 false 后 error_log 一下上下文(不要改 currency 插件本体,避免升级覆盖)
余额变成 -0.00小数位四舍五入导致这是正常现象,前端展示用 number_format 会显示 0.00
并发下超扣先查后扣非原子见 5.4 末尾的「并发隐患提示」,高频并发场景自行重写
流水的 action 全是 admin_adjust调用时忘了传 $action显式传业务标识
后台菜单「货币系统」不显示admin_menu 钩子被其他插件提前 return检查其它插件是否在 admin_menu 回调里 exit/return;本插件是硬编码注入,不注册在 PluginRegistry 里

5.10 未启用时的降级策略

货币系统通常属于可选依赖。降级分两派:

派 A:功能不可用就禁用整个入口(严格)

if (!function_exists('currency_change_balance')) {
    echo '<p class="notice">本功能需要先启用「货币系统」插件。</p>';
    return;
}

派 B:无货币系统时用虚拟积分代替(宽松,推荐)

/**
 * 统一的"给用户加钱"入口:优先用 currency,降级用 user_group 的 credit
 */
function my_grant_reward($uid, $amount, $reason) {
    // 优先货币系统
    if (function_exists('currency_change_balance')) {
        $r = currency_change_balance($uid, 'coin', $amount, 'reward', $reason, 0);
        if ($r !== false) return true;
    }
    // 降级:user_group 的积分字段
    if (function_exists('UserGroup_getProfile') && function_exists('UserGroup_updateProfile')) {
        try {
            $p = UserGroup_getProfile($uid);
            UserGroup_updateProfile($uid, ['credit' => (int)$p['credit'] + (int)$amount]);
            return true;
        } catch (Throwable $e) {}
    }
    // 都不可用:静默失败
    return false;
}

这样你的插件无论在「只装了 user_group」「只装了 currency」「两个都装」「都没装」哪种环境下都能跑。


六、综合示例:新插件同时使用四者

场景:写一个 topic_reward 插件(帖子悬赏):

  • 用户发帖后,管理员可在后台发布悬赏(用 LiteEditor 写悬赏说明)
  • 悬赏资金从发布者钱包扣(用 Currency)
  • 领取时给对方加币(用 Currency)
  • 领奖后给双方发消息通知(用 UserCenter)
  • 权限判断:只有 forum_post 权限的用户才能发悬赏(用 UserGroup)
  • 领奖成功给用户加经验值(用 UserGroup)

6.1 目录结构

content/plugins/topic_reward/
├── topic_reward.php            # 主文件 + 头注释
├── activate.php                # 建表
├── uninstall.php               # 删表
├── admin/
│   ├── register.php            # 注册后台页面
│   └── pages/
│       ├── list.php
│       └── edit.php            # 用 LiteEditor
└── assets/
    └── admin.js

6.2 topic_reward.php(主文件)

<?php
/**
 * Plugin Name: 帖子悬赏
 * Description: 帖子悬赏、回复领奖、自动发消息通知、编辑器集成
 * Version: 1.0.0
 * Author: 你
 */
if (!defined('LITELOG_ROOT')) exit('error!');

class TopicReward {
    const TABLE       = 'topic_reward';
    const CLAIM_TABLE = 'topic_reward_claim';

    /* ============================================================
     * 发布悬赏(含权限校验 + 货币扣款 + 事务回滚)
     * ============================================================ */
    public static function publish($gid, $ownerUid, $points, $description = '') {
        // 1. 权限校验(user_group)
        $canPost = true;
        if (function_exists('UserGroup_checkPermission')) {
            try {
                $canPost = UserGroup_checkPermission($ownerUid, 'forum_post');
            } catch (Throwable $e) { $canPost = true; }
        }
        if (!$canPost) {
            return ['ok' => false, 'msg' => '你没有发布悬赏的权限'];
        }

        // 2. 货币系统检测
        if (!function_exists('currency_change_balance')) {
            return ['ok' => false, 'msg' => '货币系统未启用,无法发布悬赏'];
        }

        // 3. 余额校验
        $balance = currency_get_user_balance($ownerUid, 'coin');
        if ($balance < $points) {
            return ['ok' => false, 'msg' => '金币不足,需要 ' . $points . ',当前 ' . $balance];
        }

        // 4. 扣款
        $after = currency_change_balance(
            $ownerUid, 'coin', -$points,
            'topic_reward_escrow',
            '发布悬赏:帖子 #' . $gid,
            $gid
        );
        if ($after === false) {
            return ['ok' => false, 'msg' => '扣款失败,请稍后再试'];
        }

        // 5. 写入悬赏记录
        global $DB;
        try {
            $DB->insert(self::TABLE, [
                'gid'         => (int)$gid,
                'owner_uid'   => (int)$ownerUid,
                'points'      => (int)$points,
                'description' => $description,
                'total'       => 1,
                'remaining'   => 1,
                'status'      => 'open',
                'create_time' => time(),
            ]);
        } catch (Throwable $e) {
            // ★ 业务表写入失败 → 退款
            currency_change_balance(
                $ownerUid, 'coin', $points,
                'topic_reward_refund',
                '悬赏写入失败自动退款',
                $gid
            );
            return ['ok' => false, 'msg' => '发布失败,已自动退款'];
        }

        return ['ok' => true, 'msg' => '悬赏已发布'];
    }

    /* ============================================================
     * 领取悬赏(含原子扣减 + 加币 + 消息通知 + 加经验)
     * ============================================================ */
    public static function claim($rewardId, $claimUid) {
        if (!function_exists('currency_change_balance')) {
            return ['ok' => false, 'msg' => '货币系统未启用'];
        }

        global $DB;
        $reward = $DB->fetchOne(
            "SELECT * FROM `" . DB_PREFIX . self::TABLE . "`
             WHERE id=? AND status='open'",
            [(int)$rewardId]
        );
        if (!$reward) return ['ok' => false, 'msg' => '悬赏不存在'];
        if ((int)$reward['owner_uid'] === $claimUid) {
            return ['ok' => false, 'msg' => '不能领自己的悬赏'];
        }

        // 原子扣减剩余份额(红线 5)
        $DB->pdo()->beginTransaction();
        try {
            $stmt = $DB->query(
                "UPDATE `" . DB_PREFIX . self::TABLE . "`
                 SET remaining = remaining - 1
                 WHERE id=? AND remaining > 0 AND status='open'",
                [(int)$rewardId]
            );
            if ($stmt->rowCount() === 0) {
                $DB->pdo()->rollBack();
                return ['ok' => false, 'msg' => '已被领完'];
            }
            $DB->insert(self::CLAIM_TABLE, [
                'reward_id'  => (int)$rewardId,
                'uid'        => $claimUid,
                'points'     => (int)$reward['points'],
                'claim_time' => time(),
            ]);
            $DB->pdo()->commit();
        } catch (Throwable $e) {
            if ($DB->pdo()->inTransaction()) $DB->pdo()->rollBack();
            return ['ok' => false, 'msg' => '领取失败'];
        }

        // 加币给领取人(currency)
        currency_change_balance(
            $claimUid, 'coin', (int)$reward['points'],
            'topic_reward_claim',
            '领取悬赏:帖子 #' . $reward['gid'],
            $rewardId
        );

        // 加经验(user_group)
        if (function_exists('UserGroup_getProfile') && function_exists('UserGroup_updateProfile')) {
            try {
                $p = UserGroup_getProfile($claimUid);
                UserGroup_updateProfile($claimUid, [
                    'experience' => (int)$p['experience'] + 10,
                ]);
            } catch (Throwable $e) {}
        }

        // 消息通知(user_center)
        if (function_exists('Uc_boot')) {
            try {
                Uc_boot();
                if (function_exists('Uc_addMessage')) {
                    // 通知领奖人
                    Uc_addMessage($claimUid, [
                        'from_uid' => (int)$reward['owner_uid'],
                        'type'     => 'system',
                        'title'    => '悬赏领取成功',
                        'content'  => '你领取了 ' . (int)$reward['points'] . ' 金币悬赏',
                        'link'     => BLOG_URL . '?post=' . (int)$reward['gid'],
                    ]);
                    // 通知悬赏发起人
                    Uc_addMessage((int)$reward['owner_uid'], [
                        'from_uid' => $claimUid,
                        'type'     => 'system',
                        'title'    => '你的悬赏被领取',
                        'content'  => '有用户领取了你发布的悬赏',
                        'link'     => BLOG_URL . '?post=' . (int)$reward['gid'],
                    ]);
                }
            } catch (Throwable $e) {}
        }

        return ['ok' => true, 'msg' => '领取成功'];
    }

    /* ============================================================
     * 当前用户是否可领取
     * ============================================================ */
    public static function canClaim($reward, $uid) {
        if ((int)$reward['status'] !== 1) return false;
        if ((int)$reward['owner_uid'] === $uid) return false;
        if ((int)$reward['remaining'] <= 0) return false;

        global $DB;
        $claimed = $DB->fetchColumn(
            "SELECT COUNT(*) FROM `" . DB_PREFIX . self::CLAIM_TABLE . "`
             WHERE reward_id=? AND uid=?",
            [(int)$reward['id'], $uid]
        );
        return !$claimed;
    }
}

6.3 admin/register.php(注册后台页面)

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

PluginRegistry::addAdminMenu([
    'id'       => 'topic_reward',
    'action'   => 'topic_reward',
    'title'    => '帖子悬赏',
    'icon'     => 'file',
    'priority' => 40,
]);

PluginRegistry::addAdminPage('topic_reward', [
    'title'    => '帖子悬赏管理',
    'callback' => function () {
        global $DB;
        $rewards = $DB->fetchAll(
            "SELECT r.*, b.title AS post_title
             FROM `" . DB_PREFIX . "topic_reward` r
             INNER JOIN `" . DB_PREFIX . "blog` b ON b.gid = r.gid
             ORDER BY r.id DESC LIMIT 100"
        );
        include __DIR__ . '/pages/list.php';
    },
    'assets' => [
        'js' => [BLOG_URL . 'content/plugins/topic_reward/assets/admin.js'],
    ],
]);

// 编辑页:用 LiteEditor
PluginRegistry::addAdminPage('topic_reward_edit', [
    'title'    => '编辑悬赏',
    'callback' => function () {
        global $DB;
        $id = (int)($_GET['id'] ?? 0);
        $reward = $id
            ? $DB->fetchOne("SELECT * FROM `" . DB_PREFIX . "topic_reward` WHERE id=?", [$id])
            : null;

        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $desc = $_POST['description_md'] ?? '';
            if ($id) {
                $DB->update('topic_reward', ['description' => $desc], 'id=?', [$id]);
            }
            redirect(BLOG_URL . 'admin/?action=topic_reward');
        }

        include __DIR__ . '/pages/edit.php';
    },
    'assets' => [
        'css' => [
            BLOG_URL . 'admin/assets/editor/editor.css',
        ],
        'js' => [
            BLOG_URL . 'admin/assets/editor/markdown.js',
            BLOG_URL . 'admin/assets/editor/editor.js',
        ],
    ],
]);
亮点:通过 addAdminPage 的 assets 字段,编辑器资产只在这个 action 页加载,不会污染其他后台页面。这是 LiteEditor 集成的推荐方式。

6.4 admin/pages/edit.php(用 LiteEditor)

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

<form id="rewardForm" method="post">
    <input type="text" name="title" value="<?= h($reward['title'] ?? '') ?>"
           class="title-input" placeholder="悬赏标题..." required>

    <div id="reward-editor" class="lite-editor le-mode-wysiwyg">
      <div class="le-toolbar">
        <button type="button" data-mode="wysiwyg">所见即所得</button>
        <button type="button" data-mode="markdown">Markdown</button>
        <span class="le-tb-sep"></span>
        <button type="button" data-action="bold"><b>B</b></button>
        <button type="button" data-action="italic"><i>I</i></button>
        <button type="button" data-action="link">链接</button>
        <button type="button" data-action="image">图片</button>
      </div>
      <div class="le-body">
        <div class="le-wysiwyg" contenteditable="true"></div>
        <textarea class="le-textarea"><?= h($reward['description'] ?? '') ?></textarea>
        <div class="le-preview"></div>
      </div>
      <div class="le-footer"><span class="le-status"></span></div>
    </div>

    <input type="hidden" name="description_md" id="hidden-desc">
    <button type="submit" class="btn" style="margin-top:16px">保存</button>
</form>

<script>
(function () {
    var editor = new LiteEditor('#reward-editor', {
        uploadUrl:   '<?= BLOG_URL ?>admin/upload.php',
        placeholder: '描述这个悬赏的要求...',
        autoSaveKey: 'topic_reward_draft',
        defaultMode: 'wysiwyg',
    });

    document.getElementById('rewardForm').addEventListener('submit', function () {
        document.getElementById('hidden-desc').value = editor.getMarkdown();
    });
})();
</script>

6.5 activate.php(建表)

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

$DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "topic_reward` (
    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `gid` INT UNSIGNED NOT NULL,
    `owner_uid` INT UNSIGNED NOT NULL,
    `points` INT NOT NULL DEFAULT 0,
    `description` TEXT,
    `total` INT NOT NULL DEFAULT 1,
    `remaining` INT NOT NULL DEFAULT 1,
    `status` VARCHAR(20) DEFAULT 'open',
    `create_time` BIGINT DEFAULT 0,
    KEY `idx_gid` (`gid`),
    KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

$DB->query("CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "topic_reward_claim` (
    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `reward_id` INT UNSIGNED NOT NULL,
    `uid` INT UNSIGNED NOT NULL,
    `points` INT NOT NULL DEFAULT 0,
    `claim_time` BIGINT DEFAULT 0,
    UNIQUE KEY `idx_reward_uid` (`reward_id`, `uid`),
    KEY `idx_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

6.6 uninstall.php(删表)

<?php
if (!defined('LITELOG_ROOT')) exit('error!');
global $DB;
$DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "topic_reward_claim`");
$DB->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "topic_reward`");
// 如果写过 option,也一并清:
// $DB->delete('option', "option_name LIKE 'topic_reward_%'");

6.7 四件套协同点回顾

环节用到的服务具体 API
后台编辑悬赏说明LiteEditornew LiteEditor(...) + getMarkdown()
发布悬赏前权限判断UserGroupUserGroup_checkPermission($uid, 'forum_post')
悬赏资金扣除Currencycurrency_change_balance($uid, 'coin', -$points, ...)
领奖加币Currencycurrency_change_balance($uid, 'coin', +$points, ...)
领奖加经验UserGroupUserGroup_updateProfile($uid, ['experience' => ...])
领奖消息通知UserCenterUc_addMessage($uid, [...])
幂等初始化UserCenterUc_boot()

七、API 快速索引

7.1 编辑器 LiteEditor(核心内置,非插件)

new LiteEditor(selector, options)
  .getMarkdown()     获取 MD 源码(表单提交时用)
  .getHtml()         获取渲染后 HTML
  .getValue()        = getMarkdown
  .setValue(md)      设置内容
  .triggerSave()     触发 onSave 回调

引入顺序:editor.css → markdown.js → editor.js

7.2 用户中心(前缀 Uc_)

初始化   Uc_boot()                            ← 首次调用任何 Uc_* 之前必须先跑

消息     Uc_addMessage($uid, $data)
         Uc_getMessages($uid, $page, $perPage, $onlyUnread, &$total)
         Uc_countUnreadMessages($uid)
         Uc_markMessageRead($uid, $id)
         Uc_markAllMessagesRead($uid)
         Uc_deleteMessage($uid, $id)

关注     Uc_follow / Uc_unfollow / Uc_isFollowing
         Uc_getFollowing / Uc_getFollowers

签到     Uc_signin / Uc_hasSignedToday
         Uc_getSigninCalendar / Uc_getSigninStats

收藏     Uc_favorite / Uc_unfavorite / Uc_isFavorited / Uc_getFavorites

资料     Uc_getProfile / Uc_updateProfile / Uc_getUserGroups / Uc_refreshCredit

统计     Uc_getUserStats / Uc_timeAgo / Uc_currentUid / Uc_userAvatar
         Uc_getUserComments

论坛     Uc_forumAvailable / Uc_getForumStats / Uc_getForumThreads
         Uc_getForumReplies / Uc_getReceivedReplies / Uc_forumUrl

7.3 用户组(前缀 UserGroup_)

用户组   UserGroup_getGroups / UserGroup_getGroup
         UserGroup_getUserGroups / UserGroup_getUserGroupIds
         UserGroup_getUserGroupNames
         UserGroup_setUserGroups($uid, $ids)     ← 覆盖式写入

权限     UserGroup_checkPermission($uid, $perm)   ← 管理员直通
         UserGroup_hasPermission($uid, $perm)     ← 只查组权限
         UserGroup_isAdmin / UserGroup_requireAdmin

资料     UserGroup_getProfile / UserGroup_updateProfile
         UserGroup_getUserFull / UserGroup_recordLogin

展示     UserGroup_renderBadge($uid)               ← 直接返回 HTML
         UserGroup_userAvatar($uid, $size)         ← 直接返回 HTML

当前     UserGroup_currentUid / UserGroup_currentUser

后台     UserGroup_queryUsers($page, $perPage, $kw, $status, $groupId, &$total)

7.4 货币系统(前缀 currency_)

读      currency_get_types()                      所有启用货币
        currency_get_user_balance($uid, $key?)    用户余额(单/全部)

写      currency_change_balance($uid, $key, $amount, $action, $desc, $relatedId)
        返回值:float 变动后余额 / false 失败
        ⚠️ 判失败用 === false,不要用 !$after

钩子    currency_balance_changed($uid, $key, $amount, $after, $action, $desc, $relatedId)
        触发时机:事务 commit 之后

7.5 五条调用铁律(适用于全部服务)

  1. function_exists() 前置检测 —— 依赖可能未启用。
  2. try/catch 兜底 —— 对方 API 可能异常。
  3. 提供降级路径 —— 未启用时不能让页面崩溃。
  4. 不修改对方插件本体 —— 兼容性归调用方处理,避免升级覆盖。
  5. currency_change_balance 失败判定用 === false —— 余额变 0 时不误判。

本手册基于 LiteLog v1.0.0 内核 + user_group v2.0.0 + user_center v1.0.0 + currency v1.0.0 逐行核实。API 升级时请同步更新本索引。

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