LiteLog 常用服务调用手册
版本:1.1(2026-10-10) 定位:《LiteLog 插件开发规范》配套章节,说明编辑器 / 用户中心 / 用户组 / 货币系统四大服务的调用方式,供插件开发者集成。 给 AI 下指令时,如任务涉及编辑器 / 用户 / 权限 / 消息 / 签到 / 虚拟货币,把本手册一并附上。 所有 API 均按 LiteLog v1.0.0 内核 +user_groupv2.0.0 +user_centerv1.0.0 +currencyv1.0.0 源码逐行核实。
目录
- 通用约定:跨插件调用的依赖检测
- 编辑器 LiteEditor 调用方法
- 用户中心 UserCenter API 调用方法
- 用户组 UserGroup API 调用方法
- 货币系统 Currency API 调用方法
- 综合示例:新插件同时使用四者
- API 快速索引
一、通用约定:跨插件调用的依赖检测
LiteLog 的插件机制是「启用即加载」:插件启用后,其主文件被 require_once 引入,主文件里定义的 *_函数 就是全局可用的。因此插件间调用只需要:
// 唯一需要的依赖检测方式
if (function_exists('Uc_addMessage')) {
Uc_addMessage($uid, [...]);
}
1.1 三条铁律
- 调用前必须
function_exists()检测——对方的插件可能未启用或被用户禁用。 - 调用必须 try/catch 兜底——对方的 API 可能内部异常,异常不能影响你自己的页面。
- 未启用时提供降级路径——不要让「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.js | Markdown 解析器 | 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 回调
});
参数详解:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
uploadUrl | string | '' | 图片上传接口 URL。留空会导致上传功能失效(弹"未配置上传地址")。后端必须返回 {ok:true, url:'...'}。 |
placeholder | string | '开始写作...' | 占位提示,支持 \n 换行 |
minHeight | number | 480 | 编辑区最小高度(px) |
autoSaveKey | string | 'litelog_draft' | localStorage 键名。多编辑器场景务必用不同 key,否则草稿互相覆盖。 |
autoSaveInterval | number | 30000 | 自动保存间隔(毫秒) |
smartPaste | bool | true | 是否智能处理粘贴(HTML 转 MD、代码识别) |
defaultMode | string | 'wysiwyg' | 初始模式 |
onSave | function\ | null | null |
2.5 公开 API(5 个方法)
| 方法 | 返回值 | 说明 |
|---|---|---|
getMarkdown() | string | 获取当前内容的 Markdown 源码。表单提交时应保存这个。 |
getHtml() | string | 获取渲染后的 HTML(Markdown 先解析成 HTML) |
getValue() | string | getMarkdown() 的别名 |
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"></></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 defined | script 顺序反了 | 先引 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() 内部会:
requireuser_center_api.php/user_center_page.php/user_center_forum.php- 检测数据表版本,必要时执行建表
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_credit | 5 | 基础积分 |
uc_signin_continue | 3 | 连续加成 |
实际计算:
- 连续 ≥ 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。
钩子参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
$uid | int | 用户 ID |
$currency_key | string | 货币 key |
$amount | float | 变动量(正负) |
$newBalance | float | 变动后余额 |
$action | string | 业务标识 |
$description | string | 描述 |
$related_id | int | 关联业务 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' => '领取成功'];
}
关键点:
- 扣款先,业务表写后——钱扣掉了但业务表写失败时可以补退;
- 自定义 action 用
插件名_动作前缀,避免和其他插件流水混淆; related_id传业务 ID(帖子 ID、订单 ID),便于事后对账;$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 |
|---|---|---|
| 后台编辑悬赏说明 | LiteEditor | new LiteEditor(...) + getMarkdown() |
| 发布悬赏前权限判断 | UserGroup | UserGroup_checkPermission($uid, 'forum_post') |
| 悬赏资金扣除 | Currency | currency_change_balance($uid, 'coin', -$points, ...) |
| 领奖加币 | Currency | currency_change_balance($uid, 'coin', +$points, ...) |
| 领奖加经验 | UserGroup | UserGroup_updateProfile($uid, ['experience' => ...]) |
| 领奖消息通知 | UserCenter | Uc_addMessage($uid, [...]) |
| 幂等初始化 | UserCenter | Uc_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 五条调用铁律(适用于全部服务)
function_exists()前置检测 —— 依赖可能未启用。- try/catch 兜底 —— 对方 API 可能异常。
- 提供降级路径 —— 未启用时不能让页面崩溃。
- 不修改对方插件本体 —— 兼容性归调用方处理,避免升级覆盖。
currency_change_balance失败判定用=== false—— 余额变 0 时不误判。
本手册基于 LiteLog v1.0.0 内核 + user_group v2.0.0 + user_center v1.0.0 + currency v1.0.0 逐行核实。API 升级时请同步更新本索引。