Xiuno

Xiuno BBS 幸运抽奖插件开发教程

前言

论坛经常需要举办抽奖活动,但传统抽奖方式存在公正性问题——用户无法验证抽奖结果是否被篡改。本文介绍如何在 Xiuno BBS 4.x 中实现一个公正透明、可验证的抽奖功能。

核心特性

  • 公正性保证:使用 Cloudflare drand 分布式随机信标,信标只有在开奖时间到达后才能获取
  • 可验证:任何人都可以通过 drand API 验证抽奖结果
  • 纯前端算法:抽奖算法在浏览器端执行,后端仅提供评论数据
  • 灵活配置:支持自定义开奖时间、中奖人数、起始楼层、排除重复用户等

效果预览

  • 功能分为两个模式:
  • 编辑模式:输入帖子 ID → 设置抽奖参数 → 生成抽奖链接
  • 查看模式:显示配置 → 倒计时 → 自动开奖 → 展示中奖名单

1. drand 随机信标

drand 是由 Cloudflare、Protocol Labs 等组织共同维护的分布式随机数生成网络。它的特点是:

  • 不可预测:在指定时间之前,无人能获取该时间的随机数
  • 不可篡改:随机数由多个节点共同生成,任何单一节点无法操控
  • 可验证:任何人都可以验证随机数的真实性 我们使用 drand 的 quicknet 链,每 3 秒产生一个新的随机信标。

2. 抽奖算法

  1. 根据开奖时间计算 drand round 号
  2. 等待开奖时间到达,获取该 round 的随机信标
  3. 使用 randomness (十六进制字符串) 作为 PRNG 种子
  4. 用 Fisher-Yates 洗牌算法对参与者进行全排列
  5. 取前 N 个作为中奖者

3. 确定性随机数生成器 (PRNG)

  • 使用 xorshift128+ 算法,确保相同的种子产生完全相同的随机序列:
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         # 样式(按需添加)

后端实现

1. 数据查询函数 (model/bitsflood_lucky.func.php)

<?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));
}

2. 加载 model (hook/model_inc_file.php)

include APP_PATH.'plugin/your_theme/model/bitsflood_lucky.func.php';

3. 添加路由 (hook/index_route_case_end.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>

URL 参数说明

抽奖链接格式:

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.phpinclude model 文件
  • index_route_case_end.php 中添加路由

3.清空缓存

  • rm -rf tmp/*

4.测试

  • 访问 http://your-site.com/lucky.htm
  • 输入帖子 ID,点击预览
  • 设置参数,生成抽奖链接

验证抽奖结果

  • 用户可以通过以下方式验证抽奖公正性:

  • 1.查看 drand 信标

    • 访问 https://api.drand.sh/{chain_hash}/public/{round}
    • 确认 randomness 值与页面显示一致
  • 2.重新计算
    • 使用相同的参数和 randomness
    • 执行相同的洗牌算法
    • 结果必须完全一致

常见问题

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 随机信标的公正抽奖功能。核心要点:

  1. 后端:提供评论数据 API,使用 echo json_encode() 输出
  2. 前端:通过 drand 获取随机信标,使用 xorshift128+ 和 Fisher-Yates 实现确定性洗牌
  3. 可验证:用户可通过 drand API 验证随机数真实性

希望这篇教程对你有帮助!如有问题欢迎交流讨论。

相关链接:

drand 官网 drand API 文档 Bitsflood论坛

🗨️ 评论区