论坛经常需要举办抽奖活动,但传统抽奖方式存在公正性问题——用户无法验证抽奖结果是否被篡改。本文介绍如何在 Xiuno BBS 4.x 中实现一个公正透明、可验证的抽奖功能。
drand 是由 Cloudflare、Protocol Labs 等组织共同维护的分布式随机数生成网络。它的特点是:
function createPRNG(hexSeed) {
var state0 = BigInt('0x' + hexSeed.substring(0, 16));
var state1 = BigInt('0x' + hexSeed.substring(16, 32));
return {
state0: state0,
state1: state1,
next: function() {
var s1 = this.state0;
var s0 = this.state1;
this.state0 = s0;
s1 ^= s1 << BigInt(23);
s1 ^= s1 >> BigInt(18);
s1 ^= s0;
s1 ^= s0 >> BigInt(5);
this.state1 = s1;
var result = (s0 + s1) & BigInt('0xFFFFFFFFFFFFFFFF');
return Number(result) / Number(BigInt('0xFFFFFFFFFFFFFFFF'));
}
};
}
文件结构
plugin/your_theme/
├── hook/
│ ├── index_route_case_end.php # 添加路由
│ └── model_inc_file.php # 加载 model
├── model/
│ └── bitsflood_lucky.func.php # 数据查询函数
└── view/
├── htm/
│ └── lucky.htm # 抽奖页面模板
├── js/
│ └── lucky.js # 前端抽奖逻辑
└── css/
└── your_style.css # 样式(按需添加)
<?php
/**
* 获取指定帖子的评论列表(用于抽奖)
*
* @param int $tid 帖子 ID
* @param int $page 页码
* @param int $pagesize 每页数量
* @return array 评论列表
*/
function bi t s f lo o d_lucky_get_posts($tid, $page = 1, $pagesize = 100) {
$tid = intval($tid);
$page = max(1, intval($page));
$pagesize = max(1, min(500, intval($pagesize)));
if ($tid <= 0) {
return array();
}
// 查询评论(排除主帖 isfirst=0)
$postlist = db_find('post',
array('tid' => $tid, 'isfirst' => 0),
array('pid' => 1), // 按 pid 升序排序
$page, $pagesize
);
if (empty($postlist)) {
return array();
}
// 计算起始楼层号
$floor = ($page - 1) * $pagesize + 1;
// 处理每条评论
foreach ($postlist as &$post) {
$user = user_read_cache($post['uid']);
$post['floor'] = $floor++;
$post['username'] = $user ? $user['username'] : '未知用户';
$post['avatar_url'] = $user ? $user['avatar_url'] : 'view/img/avatar.png';
// 移除敏感字段
unset($post['userip'], $post['message'], $post['message_fmt']);
}
unset($post);
return $postlist;
}
/**
* 获取帖子的评论总数(排除主帖)
*/
function bitsflood_lucky_get_post_count($tid) {
$tid = intval($tid);
if ($tid <= 0) {
return 0;
}
return db_count('post', array('tid' => $tid, 'isfirst' => 0));
}
include APP_PATH.'plugin/your_theme/model/bitsflood_lucky.func.php';
// 幸运抽奖页面
case 'lucky':
// 需要登录
if (empty($uid)) {
$referer = urlencode($_SERVER['REQUEST_URI']);
http_location(url("user-login") . "?referer={$referer}");
}
$header['title'] = '幸运抽奖';
include _include(APP_PATH.'plugin/your_theme/view/htm/lucky.htm');
break;
// 幸运抽奖 API - 获取帖子评论列表
case 'lucky_posts':
// 强制输出 JSON
header('Content-Type: application/json; charset=utf-8');
$tid = param(1, 0);
$page = param(2, 1);
$pagesize = 100;
$thread = thread_read($tid);
if (empty($thread)) {
echo json_encode(['code' => -1, 'message' => '帖子不存在']);
exit;
}
$postlist = bitsflood_lucky_get_posts($tid, $page, $pagesize);
$total = bitsflood_lucky_get_post_count($tid);
echo json_encode([
'code' => 0,
'message' => [
'thread' => [
'tid' => $thread['tid'],
'subject' => $thread['subject'],
'posts' => $thread['posts'],
'create_date' => $thread['create_date'],
],
'posts' => array_values($postlist),
'total' => $total,
'page' => $page,
'pagesize' => $pagesize,
'has_more' => ($page * $pagesize) < $total,
]
], JSON_UNESCAPED_UNICODE);
exit;
break;
注意:Xiuno 的 message() 函数在某些情况下会输出 HTML 而非 JSON,所以这里直接使用 echo json_encode() + exit。
核心 JavaScript (view/js/lucky.js) 由于代码较长,这里只展示核心部分: drand 配置
var config = {
apiBase: '', // API 基础路径,由 PHP 传入
threadBase: '', // 帖子链接基础路径
// drand quicknet 链配置
drandChain: '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971',
drandGenesisTime: 1692803367, // Unix 秒
drandPeriod: 3, // 3 秒一个 round
drandEndpoints: [
'https://api.drand.sh',
'https://drand.cloudflare.com'
]
};
计算 drand round
function calculateDrandRound(timestamp) {
var timestampSeconds = Math.floor(timestamp / 1000);
var round = Math.floor((timestampSeconds - config.drandGenesisTime) / config.drandPeriod) + 1;
return Math.max(1, round);
}
获取 drand 信标
function fetchDrandBeacon(round) {
return new Promise(function(resolve, reject) {
var endpoints = config.drandEndpoints.slice();
var chainHash = config.drandChain;
function tryFetch(index) {
if (index >= endpoints.length) {
reject(new Error('所有 drand 节点均无法访问'));
return;
}
var url = endpoints[index] + '/' + chainHash + '/public/' + round;
fetch(url)
.then(function(response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function(data) {
if (data.randomness) {
resolve(data);
} else {
throw new Error('无效的 drand 响应');
}
})
.catch(function(error) {
console.warn('drand endpoint failed:', endpoints[index], error);
tryFetch(index + 1); // 尝试下一个节点
});
}
tryFetch(0);
});
}
Fisher-Yates 洗牌
function shuffleArray(array, prng) {
var result = array.slice();
for (var i = result.length - 1; i > 0; i--) {
var j = Math.floor(prng.next() * (i + 1));
var temp = result[i];
result[i] = result[j];
result[j] = temp;
}
return result;
}
执行抽奖
function executeDraw(posts, params, drandData) {
var drawTime = parseInt(params.time, 10);
var winnerCount = parseInt(params.count, 10) || 1;
var startFloor = parseInt(params.start, 10) || 1;
var noDuplicate = params.duplicate !== 'true';
// 筛选有效参与者
var validPosts = posts.filter(function(post) {
// 楼层必须 >= 起始楼层
if (post.floor < startFloor) return false;
// 评论时间必须 < 开奖时间
if (post.create_date * 1000 >= drawTime) return false;
return true;
});
// 排除重复用户(同一用户只保留第一次评论)
if (noDuplicate) {
var seenUsers = {};
validPosts = validPosts.filter(function(post) {
if (seenUsers[post.uid]) return false;
seenUsers[post.uid] = true;
return true;
});
}
// 创建 PRNG 并洗牌
var prng = createPRNG(drandData.randomness);
var shuffled = shuffleArray(validPosts, prng);
// 选取中奖者
var winners = shuffled.slice(0, Math.min(winnerCount, shuffled.length));
// 按楼层排序
winners.sort(function(a, b) {
return a.floor - b.floor;
});
return {
winners: winners,
participants: validPosts.length,
drand: drandData
};
}
页面模板初始化
<!-- 在 lucky.htm 底部 -->
<script src="plugin/your_theme/view/js/lucky.js?v=<?php echo time(); ?>"></script>
<script>
(function() {
if (typeof LuckyDraw !== 'undefined') {
LuckyDraw.init({
apiBase: '<?php echo url("lucky_posts"); ?>',
threadBase: '<?php echo url("thread"); ?>'
});
}
})();
</script>
抽奖链接格式:
lucky.htm?thread=123&time=1767348480000&count=3&start=1&duplicate=false&mode=view
| 参数 | 说明 | 示例 |
|---|---|---|
| thread | 帖子 ID | 123 |
| time | 开奖时间戳(毫秒) | 1767348480000 |
| count | 中奖人数 | 3 |
| start | 起始楼层 | 1 |
| duplicate | 是否允许重复 | false |
| mode | 模式 | view |
1.创建文件
bitsflood_lucky.func.php 放入 plugin/your_theme/model/lucky.js 放入 plugin/your_theme/view/js/lucky.htm 页面模板2.修改 hook 文件
model_inc_file.php 中 include model 文件index_route_case_end.php 中添加路由3.清空缓存
rm -rf tmp/*4.测试
http://your-site.com/lucky.htm用户可以通过以下方式验证抽奖公正性:
1.查看 drand 信标
https://api.drand.sh/{chain_hash}/public/{round}Q: 为什么 API 返回 HTML 而不是 JSON?
Xiuno 的 message() 函数在非 AJAX 环境下会输出 HTML。解决方案:
// 不要使用 message()
// message(0, $data);
// 改用直接输出
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
Q: URL 构造错误怎么办?
PHP 的 url() 函数会添加 .htm 后缀。在 JavaScript 中构造 API URL 时需要移除:
var url = config.apiBase.replace(/\.htm$/, '') + '-' + tid + '-' + page + '.htm';
Q: 如何支持大量评论? API 使用分页加载(每次 100 条),前端会自动递归获取所有页面的数据。
本文介绍了如何在 Xiuno BBS 中实现一个基于 drand 随机信标的公正抽奖功能。核心要点:
echo json_encode() 输出希望这篇教程对你有帮助!如有问题欢迎交流讨论。
🗨️ 评论区