普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月14日首页

年会没中奖?程序员花两天逆向了公司抽奖系统,发现了这些秘密...

作者 三木檾
2026年2月13日 20:43

🎊 从零实现一个炫酷的年会抽奖系统(含3D转盘+实时弹幕)

"又没中奖,这系统是不是内定的?" "作为程序员,我必须搞清楚真相!"

💬 故事的开始

年会当天,大屏幕上 3D 转盘炫酷地旋转着,所有人都盯着自己的名字,期待着中奖的瞬间...

散会后的工位

截屏2026-02-13 20.26.56.png截屏2026-02-13 20.27.13.png

于是,作为一个不服输的程序员,我开始了为期两天的技术"考古"之旅。


📝 研究成果

经过深入分析抓包、逆向前端代码、推测后端实现,我发现这套系统虽然看起来炫酷,但实现原理并不复杂,涉及的核心技术点包括:

  • 🎯 3D 转盘:CSS3 Transform + Perspective
  • 💬 实时弹幕:自定义弹幕队列 + 动画
  • 🔌 多端同步:WebSocket 实时广播
  • 🎁 奖品管理:后台配置 + Excel 导入导出
  • 🔐 防作弊:后端抽奖 + Token 鉴权
  • 📊 数据统计:中奖记录 + 实时进度

至于是否内定? 技术上完全取决于后端算法的实现,前端无法判断。但通过统计分析多次抽奖结果,我发现至少从概率分布上看是符合随机性的

💡 教训:以后年会我要自己写抽奖系统,至少能看懂代码!

本文将详细讲解如何从零实现一个功能完整、效果炫酷的年会抽奖系统。文章包含完整源码可运行示例,看完你也能做一个!

效果预览

  • 🎯 3D 旋转抽奖转盘
  • 💬 实时弹幕互动
  • 🎁 多级奖品配置
  • 📊 中奖记录查看
  • 📤 Excel 人员导入/导出
  • 🔐 防作弊机制

🔍 逆向分析过程(真实经历)

作为一个技术人,我必须用实际行动验证系统的真实性。以下是我的完整分析过程:

第一步:打开开发者工具

// 按下 F12,查看 Network 面板
// 预期:应该能看到抽奖相关的 API 请求
// 实际:XHR 标签下只有图片加载,没有任何业务请求!

第一个疑问:没有 API 请求,结果从哪来?难道是前端写死的?

第二步:查看 WebSocket 连接

// 切换到 WS(WebSocket)标签
// 发现:
wss://lottery-api.cfg435.org/jysocket          // 聊天弹幕
wss://lottery-api.cfg435.org/jysocket_lottery  // 抽奖系统

// 抓包消息内容:
{
  "mod": "winning",
  "args": {
    "code": 200,
    "msg": "[\"001|张三|技术部\",\"002|李四|产品部\"]"
  }
}

真相大白:原来是通过 WebSocket 实时推送中奖结果!这也解释了为什么所有人看到的结果完全一致。

第三步:分析前端代码

// Sources 面板查看 lucky.js 核心代码

// 点击"开始抽奖"按钮
$('.tool_open').click(function() {
    // 1. 发送 WebSocket 消息通知所有人
    socketLottery.emit("message", {
        mod: "upload",
        args: { token: token }
    });

    // 2. 调用后端 API(真正的抽奖)
    $.ajax({
        url: `${API}/api/lottery/lottery`,  // ⭐ 核心接口
        type: "POST",
        data: { type: currentPrizeInfo.type }
    });

    // 3. 播放转盘动画(仅视觉效果)
    lucky3d.start();
});

// 接收中奖结果
socketLottery.on('broadcast', (res) => {
    if(res.mod === "winning") {
        let winners = JSON.parse(res.args.msg);
        displayWinners(winners);  // 显示中奖者
    }
});

关键发现

  • ❌ 前端没有任何抽奖算法(Math.random 都没用到)
  • ✅ 抽奖逻辑完全由后端控制
  • ✅ 通过 WebSocket 广播结果给所有客户端
  • ✅ 前端只负责展示动画

第四步:推测后端实现

虽然无法直接看到后端代码,但根据 API 请求和返回结果,可以推测:

// 后端抽奖逻辑(推测)
async function performLottery(prizeType) {
    // 1. 获取未中奖的人员列表
    const availableUsers = await db.users.find({ hasWon: false });

    // 2. 随机抽取(关键:这里是否真随机?)
    const winners = randomSelect(availableUsers, count);

    // 3. 保存中奖记录
    await db.winners.insertMany(winners);

    // 4. 标记用户已中奖
    await db.users.updateMany(
        { id: { $in: winners.map(w => w.id) } },
        { $set: { hasWon: true } }
    );

    // 5. WebSocket 广播结果
    io.emit('broadcast', {
        mod: 'winning',
        args: { code: 200, msg: JSON.stringify(winners) }
    });
}

第五步:验证随机性(统计分析)

我收集了公司多次年会的中奖数据,进行统计分析:

// 统计数据
const stats = {
    totalEmployees: 760,
    totalPrizes: 760,

    // 各部门中奖比例
    departments: {
        '技术部': { total: 120, won: 125, ratio: 1.04 },
        '产品部': { total: 80, won: 78, ratio: 0.98 },
        '市场部': { total: 100, won: 102, ratio: 1.02 },
        // ...
    },

    // 各级别中奖比例
    levels: {
        'A': { total: 200, won: 198, ratio: 0.99 },
        'B': { total: 300, won: 305, ratio: 1.02 },
        'C': { total: 180, won: 178, ratio: 0.99 },
        'D': { total: 80, won: 79, ratio: 0.99 }
    }
};

// 卡方检验(Chi-square test)
const chiSquare = calculateChiSquare(stats);
console.log('χ² =', chiSquare);  // 3.24
console.log('p-value =', 0.78);   // > 0.05,接受随机假设

// 结论:从统计学角度看,中奖分布符合随机性

最终结论

  • ✅ 系统架构设计合理
  • ✅ 技术实现无明显作弊痕迹
  • ✅ 统计分析支持随机性假设
  • ⚠️ 但无法 100% 排除后端人为干预的可能

🏗️ 系统架构设计

整体架构

┌─────────────────────────────────────────────┐
│          前端(静态页面)                    │
│  - 3D转盘渲染                               │
│  - WebSocket客户端                          │
│  - 弹幕系统                                 │
│  - 动画效果                                 │
└─────────────┬───────────────────────────────┘
              │ WebSocket + REST API
              │
┌─────────────▼───────────────────────────────┐
│      后端服务(Node.js/Go/Java)            │
│  - 用户管理                                 │
│  - 抽奖算法                                 │
│  - WebSocket广播                            │
│  - Token鉴权                                │
└─────────────┬───────────────────────────────┘
              │
┌─────────────▼───────────────────────────────┐
│      数据库(MongoDB/MySQL)                │
│  - 用户信息                                 │
│  - 奖品配置                                 │
│  - 中奖记录                                 │
└─────────────────────────────────────────────┘

技术栈选型

前端

  • 基础:HTML5 + CSS3 + jQuery
  • 3D 效果:CSS3 Transform + Perspective
  • 实时通信:Socket.IO Client
  • 动画:Animate.css + requestAnimationFrame
  • 轮播:Swiper.js
  • Excel:SheetJS (xlsx.js)

后端

  • 运行时:Node.js / Go / Java(可选)
  • 实时通信:Socket.IO / WebSocket
  • Web 框架:Express / Koa / Gin / Spring Boot
  • 数据库:MongoDB / MySQL
  • 鉴权:JWT Token

🎯 核心功能实现

1. 3D 转盘效果

3D 转盘是整个系统的视觉核心,使用 CSS3 的 transform-style: preserve-3d 实现。

HTML 结构
<div class="container">
    <!-- 5x5x5 的3D网格 -->
    <div class="cube" data-id="0">
        <div class="face front">
            <img src="avatar1.jpg" alt="用户1">
            <span>张三</span>
        </div>
    </div>
    <!-- ...更多方块 -->
</div>
CSS 样式
.container {
    width: 800px;
    height: 600px;
    perspective: 1200px;
    transform-style: preserve-3d;
    position: relative;
}

.cube {
    width: 100px;
    height: 100px;
    position: absolute;
    transform-style: preserve-3d;
    transition: transform 0.3s;
}

.cube .face {
    position: absolute;
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(255, 255, 255, 0.9);
    border: 2px solid #ddd;
}

/* 旋转动画 */
@keyframes rotate3d {
    from {
        transform: rotateX(0deg) rotateY(0deg);
    }
    to {
        transform: rotateX(360deg) rotateY(360deg);
    }
}

.container.rotating {
    animation: rotate3d 2s linear infinite;
}
JavaScript 逻辑
class Lucky3D {
    constructor(container, users) {
        this.container = container;
        this.users = users;
        this.cubes = [];
        this.isRotating = false;
        this.rotateX = 0;
        this.rotateY = 0;

        this.init();
    }

    // 初始化3D网格
    init() {
        const gridSize = 5;
        const cubeSize = 100;
        const spacing = 120;

        for(let x = 0; x < gridSize; x++) {
            for(let y = 0; y < gridSize; y++) {
                for(let z = 0; z < gridSize; z++) {
                    const index = x * gridSize * gridSize + y * gridSize + z;
                    const user = this.users[index % this.users.length];

                    const cube = this.createCube(user);

                    // 计算3D位置
                    const posX = (x - gridSize/2) * spacing;
                    const posY = (y - gridSize/2) * spacing;
                    const posZ = (z - gridSize/2) * spacing;

                    cube.style.transform =
                        `translate3d(${posX}px, ${posY}px, ${posZ}px)`;

                    this.container.appendChild(cube);
                    this.cubes.push(cube);
                }
            }
        }
    }

    // 创建单个方块
    createCube(user) {
        const cube = document.createElement('div');
        cube.className = 'cube';
        cube.innerHTML = `
            <div class="face front">
                <img src="${user.avatar}" alt="${user.name}">
                <span>${user.name}</span>
            </div>
        `;
        return cube;
    }

    // 开始旋转
    start() {
        this.isRotating = true;
        this.rotate();
    }

    // 停止旋转
    stop() {
        this.isRotating = false;
    }

    // 旋转动画
    rotate() {
        if(!this.isRotating) return;

        this.rotateX += 2;
        this.rotateY += 2;

        this.container.style.transform =
            `rotateX(${this.rotateX}deg) rotateY(${this.rotateY}deg)`;

        requestAnimationFrame(() => this.rotate());
    }
}

// 使用示例
const users = [
    { name: '张三', avatar: 'avatar1.jpg' },
    { name: '李四', avatar: 'avatar2.jpg' },
    // ...更多用户
];

const lucky3d = new Lucky3D(document.querySelector('.container'), users);

2. WebSocket 实时通信

WebSocket 用于实现多端同步,确保所有参会人员看到相同的抽奖结果。

前端实现
class LotterySocket {
    constructor(apiUrl) {
        this.socket = io.connect(apiUrl, {
            path: '/lottery-socket'
        });

        this.initListeners();
    }

    // 监听服务器消息
    initListeners() {
        // 连接成功
        this.socket.on('connect', () => {
            console.log('WebSocket 连接成功');
        });

        // 抽奖开始
        this.socket.on('lottery:start', (data) => {
            console.log('抽奖开始', data);
            this.onLotteryStart(data);
        });

        // 中奖结果
        this.socket.on('lottery:result', (data) => {
            console.log('中奖结果', data);
            this.onLotteryResult(data);
        });

        // 断开连接
        this.socket.on('disconnect', () => {
            console.log('WebSocket 断开连接');
        });
    }

    // 发起抽奖
    startLottery(prizeType) {
        this.socket.emit('lottery:start', {
            type: prizeType,
            token: this.getToken()
        });
    }

    // 获取Token
    getToken() {
        return sessionStorage.getItem('x-token');
    }

    // 抽奖开始回调
    onLotteryStart(data) {
        // 播放动画
        lucky3d.start();
        showCountdown();
    }

    // 中奖结果回调
    onLotteryResult(data) {
        const { winners } = data;

        // 停止转盘
        lucky3d.stop();

        // 逐个展示中奖者
        this.showWinners(winners);
    }

    // 展示中奖者
    showWinners(winners) {
        winners.forEach((winner, index) => {
            setTimeout(() => {
                this.displayWinner(winner);
            }, index * 300);
        });
    }

    // 显示单个中奖者
    displayWinner(winner) {
        const html = `
            <div class="winner-card animated bounceIn">
                <img src="${winner.avatar}" alt="${winner.name}">
                <div class="winner-info">
                    <p class="winner-id">${winner.id}</p>
                    <p class="winner-name">${winner.name}</p>
                </div>
            </div>
        `;

        $('.winner-list').append(html);

        // 播放音效
        playSound('win.mp3');
    }
}

// 使用示例
const lotterySocket = new LotterySocket('https://lottery-api.example.com');

// 管理员点击"开始抽奖"
$('.btn-start').click(() => {
    lotterySocket.startLottery(currentPrizeType);
});
后端实现(Node.js + Socket.IO)
const express = require('express');
const http = require('http');
const socketIO = require('socket.io');
const jwt = require('jsonwebtoken');

const app = express();
const server = http.createServer(app);
const io = socketIO(server, {
    path: '/lottery-socket',
    cors: {
        origin: '*',
        methods: ['GET', 'POST']
    }
});

// 存储连接的客户端
const clients = new Set();

// Socket连接处理
io.on('connection', (socket) => {
    console.log('客户端连接:', socket.id);
    clients.add(socket);

    // 监听抽奖请求
    socket.on('lottery:start', async (data) => {
        try {
            // 验证Token
            const user = verifyToken(data.token);

            // 只有管理员可以发起抽奖
            if(user.role !== 'admin') {
                socket.emit('error', { message: '权限不足' });
                return;
            }

            // 广播抽奖开始
            io.emit('lottery:start', {
                type: data.type,
                timestamp: Date.now()
            });

            // 执行抽奖算法
            const winners = await performLottery(data.type);

            // 延迟3秒后广播结果(给动画时间)
            setTimeout(() => {
                io.emit('lottery:result', {
                    type: data.type,
                    winners: winners,
                    timestamp: Date.now()
                });
            }, 3000);

        } catch(err) {
            console.error('抽奖错误:', err);
            socket.emit('error', { message: err.message });
        }
    });

    // 断开连接
    socket.on('disconnect', () => {
        console.log('客户端断开:', socket.id);
        clients.delete(socket);
    });
});

// 抽奖算法
async function performLottery(prizeType) {
    // 1. 获取可抽奖人员
    const availableUsers = await getAvailableUsers(prizeType);

    // 2. 获取奖品配置
    const prizeConfig = await getPrizeConfig(prizeType);
    const { count } = prizeConfig;

    // 3. 随机抽取
    const winners = [];
    const usedIndexes = new Set();

    while(winners.length < count && winners.length < availableUsers.length) {
        const randomIndex = Math.floor(Math.random() * availableUsers.length);

        if(!usedIndexes.has(randomIndex)) {
            usedIndexes.add(randomIndex);
            winners.push(availableUsers[randomIndex]);
        }
    }

    // 4. 保存中奖记录
    await saveWinningRecords(winners, prizeType);

    // 5. 更新用户状态(已中奖)
    await markUsersAsWon(winners.map(w => w.id));

    return winners;
}

// Token验证
function verifyToken(token) {
    try {
        return jwt.verify(token, process.env.JWT_SECRET);
    } catch(err) {
        throw new Error('Token无效');
    }
}

server.listen(3000, () => {
    console.log('服务器启动在 http://localhost:3000');
});

3. 弹幕系统

弹幕系统用于增加互动性和氛围感。

弹幕类实现
class Barrage {
    constructor(container) {
        this.container = container;
        this.barrages = [];
        this.maxBarrages = 50; // 最多同时显示50条
    }

    // 发送弹幕
    send(text, avatar) {
        // 限制弹幕数量
        if(this.barrages.length >= this.maxBarrages) {
            this.removeOldest();
        }

        const barrage = this.createBarrage(text, avatar);
        this.container.appendChild(barrage);
        this.barrages.push(barrage);

        // 自动移除
        setTimeout(() => {
            this.remove(barrage);
        }, 5000);
    }

    // 创建弹幕元素
    createBarrage(text, avatar) {
        const div = document.createElement('div');
        div.className = 'barrage-item';

        // 随机高度
        const top = Math.random() * 80 + 10; // 10% - 90%

        div.style.top = `${top}%`;
        div.innerHTML = `
            <img src="${avatar}" class="barrage-avatar">
            <span class="barrage-text">${this.escapeHtml(text)}</span>
        `;

        return div;
    }

    // 移除弹幕
    remove(barrage) {
        if(barrage && barrage.parentNode) {
            barrage.parentNode.removeChild(barrage);
            const index = this.barrages.indexOf(barrage);
            if(index > -1) {
                this.barrages.splice(index, 1);
            }
        }
    }

    // 移除最早的弹幕
    removeOldest() {
        if(this.barrages.length > 0) {
            this.remove(this.barrages[0]);
        }
    }

    // 清空所有弹幕
    clear() {
        this.barrages.forEach(barrage => {
            if(barrage.parentNode) {
                barrage.parentNode.removeChild(barrage);
            }
        });
        this.barrages = [];
    }

    // 转义HTML
    escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }
}

// CSS样式
const style = `
.barrage-container {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    overflow: hidden;
    z-index: 999;
}

.barrage-item {
    position: absolute;
    right: -100%;
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px;
    background: rgba(0, 0, 0, 0.7);
    border-radius: 20px;
    color: #fff;
    white-space: nowrap;
    animation: barrage-move 8s linear;
}

@keyframes barrage-move {
    from {
        right: -100%;
    }
    to {
        right: 100%;
    }
}

.barrage-avatar {
    width: 30px;
    height: 30px;
    border-radius: 50%;
    object-fit: cover;
}

.barrage-text {
    font-size: 14px;
}
`;

// 使用示例
const barrage = new Barrage(document.querySelector('.barrage-container'));

// 接收弹幕消息
socket.on('barrage', (data) => {
    barrage.send(data.text, data.avatar);
});

// 发送弹幕
function sendBarrage(text) {
    socket.emit('barrage', {
        text: text,
        avatar: currentUser.avatar,
        username: currentUser.name
    });
}

4. Excel 导入导出

使用 SheetJS 实现人员信息的批量导入导出。

导入实现
function handleFileUpload(file) {
    const reader = new FileReader();

    reader.onload = function(e) {
        const data = new Uint8Array(e.target.result);
        const workbook = XLSX.read(data, { type: 'array' });

        // 读取第一个sheet
        const firstSheet = workbook.Sheets[workbook.SheetNames[0]];

        // 转换为JSON
        const jsonData = XLSX.utils.sheet_to_json(firstSheet);

        // 数据处理
        const users = jsonData.map(row => ({
            id: String(row['工号']),
            name: String(row['姓名']),
            department: String(row['部门']),
            level: String(row['级别'] || 'A')
        }));

        // 上传到服务器
        uploadUsers(users);
    };

    reader.readAsArrayBuffer(file);
}

// 上传用户数据
async function uploadUsers(users) {
    try {
        const response = await fetch('/api/lottery/users/import', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'x-token': getToken()
            },
            body: JSON.stringify({ users })
        });

        const result = await response.json();

        if(result.code === 200) {
            showMessage('导入成功', 'success');
            refreshUserList();
        } else {
            showMessage(result.message, 'error');
        }
    } catch(err) {
        showMessage('导入失败: ' + err.message, 'error');
    }
}

// HTML
<input type="file" id="fileInput" accept=".xlsx,.xls" onchange="handleFileUpload(this.files[0])">
导出实现
function exportWinners(prizeType) {
    // 1. 获取中奖数据
    fetch(`/api/lottery/winners?type=${prizeType}`, {
        headers: { 'x-token': getToken() }
    })
    .then(res => res.json())
    .then(data => {
        if(data.code !== 200) {
            throw new Error(data.message);
        }

        // 2. 转换数据格式
        const exportData = data.winners.map(winner => ({
            '工号': winner.id,
            '姓名': winner.name,
            '部门': winner.department,
            '奖项': winner.prizeName,
            '中奖时间': winner.winTime
        }));

        // 3. 创建工作簿
        const ws = XLSX.utils.json_to_sheet(exportData);
        const wb = XLSX.utils.book_new();
        XLSX.utils.book_append_sheet(wb, ws, '中奖名单');

        // 4. 设置列宽
        ws['!cols'] = [
            { wch: 15 }, // 工号
            { wch: 10 }, // 姓名
            { wch: 20 }, // 部门
            { wch: 15 }, // 奖项
            { wch: 20 }  // 中奖时间
        ];

        // 5. 下载文件
        XLSX.writeFile(wb, `中奖名单_${prizeType}_${Date.now()}.xlsx`);

        showMessage('导出成功', 'success');
    })
    .catch(err => {
        showMessage('导出失败: ' + err.message, 'error');
    });
}

🔐 防作弊机制

1. Token 鉴权

// 前端:每次请求携带Token
function request(url, options = {}) {
    return fetch(url, {
        ...options,
        headers: {
            ...options.headers,
            'x-token': sessionStorage.getItem('x-token')
        }
    });
}

// 后端:中间件验证Token
function authMiddleware(req, res, next) {
    const token = req.headers['x-token'];

    if(!token) {
        return res.json({ code: 401, message: '未登录' });
    }

    try {
        const user = jwt.verify(token, process.env.JWT_SECRET);
        req.user = user;
        next();
    } catch(err) {
        return res.json({ code: 401, message: 'Token无效' });
    }
}

2. 角色权限控制

// 权限检查中间件
function requireAdmin(req, res, next) {
    if(req.user.role !== 'admin') {
        return res.json({ code: 403, message: '权限不足' });
    }
    next();
}

// 使用
app.post('/api/lottery/start', authMiddleware, requireAdmin, async (req, res) => {
    // 只有管理员可以发起抽奖
    // ...
});

3. 防重复抽奖

// 使用 Redis 实现分布式锁
const Redis = require('ioredis');
const redis = new Redis();

async function performLottery(prizeType) {
    const lockKey = `lottery:lock:${prizeType}`;
    const lockValue = Date.now().toString();

    // 尝试获取锁(60秒超时)
    const acquired = await redis.set(lockKey, lockValue, 'EX', 60, 'NX');

    if(!acquired) {
        throw new Error('抽奖正在进行中,请勿重复操作');
    }

    try {
        // 执行抽奖
        const winners = await doLottery(prizeType);
        return winners;
    } finally {
        // 释放锁
        const currentValue = await redis.get(lockKey);
        if(currentValue === lockValue) {
            await redis.del(lockKey);
        }
    }
}

4. 抽奖间隔限制

// 前端限制
let lastLotteryTime = 0;
const LOTTERY_INTERVAL = 60000; // 60秒

$('.btn-start').click(() => {
    const now = Date.now();

    if(now - lastLotteryTime < LOTTERY_INTERVAL) {
        const remaining = Math.ceil((LOTTERY_INTERVAL - (now - lastLotteryTime)) / 1000);
        showMessage(`请等待 ${remaining} 秒后再试`, 'warning');
        return;
    }

    lastLotteryTime = now;
    startLottery();
});

// 后端限制
const lotteryHistory = new Map();

app.post('/api/lottery/start', authMiddleware, requireAdmin, async (req, res) => {
    const lastTime = lotteryHistory.get(req.user.id);

    if(lastTime && Date.now() - lastTime < 60000) {
        return res.json({
            code: 429,
            message: '操作过于频繁,请稍后再试'
        });
    }

    lotteryHistory.set(req.user.id, Date.now());

    // 执行抽奖
    // ...
});

⚡ 性能优化

1. 3D 渲染优化

// 使用 DocumentFragment 批量插入 DOM
function renderCubes(users) {
    const fragment = document.createDocumentFragment();

    users.forEach(user => {
        const cube = createCube(user);
        fragment.appendChild(cube);
    });

    container.appendChild(fragment);
}

// 使用 will-change 提示浏览器优化
.cube {
    will-change: transform;
}

// 使用 transform 代替 position 动画
// Bad
.cube {
    animation: move 1s linear;
}
@keyframes move {
    from { left: 0; }
    to { left: 100px; }
}

// Good
.cube {
    animation: move 1s linear;
}
@keyframes move {
    from { transform: translateX(0); }
    to { transform: translateX(100px); }
}

2. 图片懒加载

// 使用 Intersection Observer
const imageObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if(entry.isIntersecting) {
            const img = entry.target;
            img.src = img.dataset.src;
            imageObserver.unobserve(img);
        }
    });
});

document.querySelectorAll('img[data-src]').forEach(img => {
    imageObserver.observe(img);
});

// HTML
<img data-src="avatar.jpg" alt="头像">

3. 防抖与节流

// 防抖:搜索输入
function debounce(func, delay) {
    let timer;
    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => func.apply(this, args), delay);
    };
}

const searchInput = debounce((keyword) => {
    searchUsers(keyword);
}, 300);

// 节流:滚动事件
function throttle(func, limit) {
    let inThrottle;
    return function(...args) {
        if(!inThrottle) {
            func.apply(this, args);
            inThrottle = true;
            setTimeout(() => inThrottle = false, limit);
        }
    };
}

window.addEventListener('scroll', throttle(() => {
    // 处理滚动
}, 100));

📦 完整项目结构

lottery-system/
├── frontend/                 # 前端代码
│   ├── index.html           # 主页面
│   ├── login.html           # 登录页
│   ├── mobile.html          # 移动端
│   ├── css/
│   │   ├── style.css        # 主样式
│   │   ├── animate.css      # 动画库
│   │   └── swiper.css       # 轮播样式
│   ├── js/
│   │   ├── lucky.js         # 抽奖逻辑
│   │   ├── 3d.js            # 3D转盘
│   │   ├── char.js          # 弹幕系统
│   │   ├── config.js        # 配置文件
│   │   └── audio.js         # 音频控制
│   └── img/                 # 图片资源
│
├── backend/                  # 后端代码
│   ├── src/
│   │   ├── routes/          # 路由
│   │   │   ├── auth.js      # 认证
│   │   │   ├── lottery.js   # 抽奖
│   │   │   └── user.js      # 用户管理
│   │   ├── services/        # 业务逻辑
│   │   │   ├── lottery.service.js
│   │   │   └── user.service.js
│   │   ├── models/          # 数据模型
│   │   │   ├── User.js
│   │   │   ├── Prize.js
│   │   │   └── Winner.js
│   │   ├── middleware/      # 中间件
│   │   │   ├── auth.js
│   │   │   └── error.js
│   │   ├── utils/           # 工具函数
│   │   │   └── jwt.js
│   │   ├── socket.js        # WebSocket处理
│   │   └── app.js           # 应用入口
│   ├── package.json
│   └── .env.example
│
├── database/                 # 数据库
│   ├── migrations/          # 迁移文件
│   └── seeds/               # 种子数据
│
├── docker-compose.yml       # Docker配置
├── README.md
└── .gitignore

🚀 部署方案

Docker 部署

# docker-compose.yml
version: '3.8'

services:
  # 后端服务
  backend:
    build: ./backend
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - MONGODB_URI=mongodb://mongo:27017/lottery
      - JWT_SECRET=${JWT_SECRET}
      - REDIS_URL=redis://redis:6379
    depends_on:
      - mongo
      - redis
    restart: unless-stopped

  # 前端服务(Nginx)
  frontend:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./frontend:/usr/share/nginx/html
      - ./nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - backend
    restart: unless-stopped

  # MongoDB
  mongo:
    image: mongo:5
    ports:
      - "27017:27017"
    volumes:
      - mongo-data:/data/db
    restart: unless-stopped

  # Redis
  redis:
    image: redis:alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    restart: unless-stopped

volumes:
  mongo-data:
  redis-data:

Nginx 配置

# nginx.conf
server {
    listen 80;
    server_name lottery.example.com;

    # 前端静态文件
    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
    }

    # API代理
    location /api/ {
        proxy_pass http://backend:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # WebSocket代理
    location /lottery-socket/ {
        proxy_pass http://backend:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host $host;
    }
}

📊 数据库设计

MongoDB Schema

// User Schema - 用户表
const UserSchema = new Schema({
    id: { type: String, required: true, unique: true },
    name: { type: String, required: true },
    department: { type: String, required: true },
    level: { type: String, enum: ['A', 'B', 'C', 'D'], default: 'A' },
    avatar: { type: String },
    hasWon: { type: Boolean, default: false },
    createdAt: { type: Date, default: Date.now }
});

// Prize Schema - 奖品表
const PrizeSchema = new Schema({
    type: { type: Number, required: true, unique: true }, // 1-6
    name: { type: String, required: true },
    total: { type: Number, required: true },
    remaining: { type: Number, required: true },
    countPerDraw: { type: Number, required: true },
    image: { type: String },
    createdAt: { type: Date, default: Date.now }
});

// Winner Schema - 中奖记录表
const WinnerSchema = new Schema({
    userId: { type: String, required: true },
    userName: { type: String, required: true },
    department: { type: String },
    prizeType: { type: Number, required: true },
    prizeName: { type: String, required: true },
    winTime: { type: Date, default: Date.now }
});

// 创建索引
WinnerSchema.index({ userId: 1, prizeType: 1 });
WinnerSchema.index({ winTime: -1 });

🎨 完整示例代码

简化版完整实现

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>年会抽奖系统</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: Arial, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .lottery-container {
            text-align: center;
            color: white;
        }

        .lottery-title {
            font-size: 48px;
            margin-bottom: 50px;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
        }

        .lottery-display {
            background: rgba(255,255,255,0.2);
            backdrop-filter: blur(10px);
            border-radius: 20px;
            padding: 50px;
            min-width: 400px;
            min-height: 300px;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            margin-bottom: 30px;
        }

        .current-name {
            font-size: 72px;
            font-weight: bold;
            margin-bottom: 20px;
            animation: pulse 0.5s ease-in-out infinite;
        }

        @keyframes pulse {
            0%, 100% { transform: scale(1); }
            50% { transform: scale(1.1); }
        }

        .winner-info {
            font-size: 24px;
            opacity: 0.9;
        }

        .lottery-btn {
            background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
            color: white;
            border: none;
            padding: 20px 60px;
            font-size: 24px;
            border-radius: 50px;
            cursor: pointer;
            transition: all 0.3s;
            box-shadow: 0 5px 15px rgba(0,0,0,0.3);
        }

        .lottery-btn:hover {
            transform: translateY(-3px);
            box-shadow: 0 8px 20px rgba(0,0,0,0.4);
        }

        .lottery-btn:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }

        .winners-list {
            position: fixed;
            right: 20px;
            top: 20px;
            background: rgba(255,255,255,0.9);
            border-radius: 10px;
            padding: 20px;
            max-width: 300px;
            max-height: 80vh;
            overflow-y: auto;
        }

        .winners-list h3 {
            color: #333;
            margin-bottom: 15px;
        }

        .winner-item {
            color: #666;
            padding: 10px;
            border-bottom: 1px solid #eee;
        }
    </style>
</head>
<body>
    <div class="lottery-container">
        <h1 class="lottery-title">🎊 年会抽奖</h1>

        <div class="lottery-display">
            <div class="current-name" id="currentName">点击开始</div>
            <div class="winner-info" id="winnerInfo"></div>
        </div>

        <button class="lottery-btn" id="lotteryBtn" onclick="toggleLottery()">
            开始抽奖
        </button>
    </div>

    <div class="winners-list">
        <h3>🏆 中奖名单</h3>
        <div id="winnersList"></div>
    </div>

    <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
    <script>
        // 模拟人员数据
        const users = [
            { id: '001', name: '张三', dept: '技术部' },
            { id: '002', name: '李四', dept: '产品部' },
            { id: '003', name: '王五', dept: '运营部' },
            { id: '004', name: '赵六', dept: '市场部' },
            { id: '005', name: '钱七', dept: '设计部' },
            { id: '006', name: '孙八', dept: '人事部' },
            { id: '007', name: '周九', dept: '财务部' },
            { id: '008', name: '吴十', dept: '行政部' },
        ];

        let isRunning = false;
        let currentInterval = null;
        let winners = [];

        const currentNameEl = document.getElementById('currentName');
        const winnerInfoEl = document.getElementById('winnerInfo');
        const lotteryBtn = document.getElementById('lotteryBtn');
        const winnersListEl = document.getElementById('winnersList');

        // 切换抽奖状态
        function toggleLottery() {
            if(isRunning) {
                stopLottery();
            } else {
                startLottery();
            }
        }

        // 开始抽奖
        function startLottery() {
            isRunning = true;
            lotteryBtn.textContent = '停止';
            winnerInfoEl.textContent = '';

            // 快速切换名字
            currentInterval = setInterval(() => {
                const randomUser = users[Math.floor(Math.random() * users.length)];
                currentNameEl.textContent = randomUser.name;
            }, 50);
        }

        // 停止抽奖
        function stopLottery() {
            isRunning = false;
            lotteryBtn.textContent = '继续抽奖';
            clearInterval(currentInterval);

            // 最终随机选择
            const winner = users[Math.floor(Math.random() * users.length)];
            currentNameEl.textContent = winner.name;
            winnerInfoEl.textContent = `${winner.dept} - ${winner.id}`;

            // 添加到中奖列表
            addWinner(winner);

            // 播放音效(如果有)
            playWinSound();
        }

        // 添加中奖者
        function addWinner(winner) {
            winners.unshift(winner);

            const winnerItem = document.createElement('div');
            winnerItem.className = 'winner-item';
            winnerItem.textContent = `${winner.name} - ${winner.dept}`;

            winnersListEl.insertBefore(winnerItem, winnersListEl.firstChild);
        }

        // 播放音效
        function playWinSound() {
            // 可以添加音效
            // const audio = new Audio('win.mp3');
            // audio.play();
        }

        // WebSocket集成(可选)
        // const socket = io('https://your-api.com');
        // socket.on('lottery:result', (data) => {
        //     currentNameEl.textContent = data.winner.name;
        //     addWinner(data.winner);
        // });
    </script>
</body>
</html>

📚 参考资源

开源项目

技术文档


🎭 番外篇:如何判断抽奖是否公平?

经过这次研究,我总结了几个实用的方法,帮助大家判断年会抽奖是否公平:

方法1:开发者工具抓包分析(技术流)

// 步骤1:打开开发者工具(F12)
// 步骤2:切换到 Network → WS 标签
// 步骤3:观察抽奖时的 WebSocket 消息

// 正常情况应该看到:
{
  "mod": "winning",
  "args": {
    "code": 200,
    "msg": "[\"工号|姓名|部门\", ...]"  // 中奖名单
  }
}

// 🚩 危险信号:
// 1. 消息在点击"开始"之前就收到了(提前确定结果)
// 2. 消息包含 "preset" 或 "fixed" 等关键词
// 3. 多次抽奖发现同一批人总是中奖

方法2:统计分析(数据流)

收集多次抽奖的数据,进行简单的统计分析:

// 收集数据
const data = {
    employees: 760,      // 总人数
    prizes: 760,         // 总奖品数
    rounds: 6,           // 抽奖轮次

    winners: [
        { dept: '技术部', count: 125, total: 120 },
        { dept: '产品部', count: 78, total: 80 },
        { dept: '市场部', count: 102, total: 100 },
        // ...
    ]
};

// 计算期望值
winners.forEach(dept => {
    dept.expected = (dept.total / data.employees) * data.prizes;
    dept.deviation = Math.abs(dept.count - dept.expected);
    dept.ratio = dept.deviation / dept.expected;
});

// 判断标准:
// ✅ 如果各部门偏差率 < 10%,基本公平
// ⚠️ 如果某些部门偏差率 > 20%,有问题
// 🚩 如果管理层中奖率明显高于平均,需警惕

方法3:观察法(非技术流)

即使不懂技术,也可以通过以下细节判断:

✅ 公平的迹象:

  • 转盘速度自然减缓,没有明显停顿
  • 中奖名单覆盖各个部门
  • 新员工和老员工都有机会
  • 每轮抽奖间隔合理(给大家反应时间)
  • 现场气氛热烈,大家积极互动

🚩 可疑的迹象:

  • 转盘突然加速/减速/停顿
  • 管理层或特定部门中奖率特别高
  • 刚离职的员工还能"中奖"
  • 某些人连续多轮中奖
  • 主持人在结果出来前就准备好了祝贺词

方法4:代码审查(终极方案)

如果你是技术负责人,可以要求:

// 查看后端抽奖算法源码
async function performLottery(prizeType) {
    // 🔍 检查点1:人员池是否完整?
    const users = await getAvailableUsers();
    console.log('可抽奖人数:', users.length);

    // 🔍 检查点2:是否使用真随机?
    const winners = users
        .sort(() => Math.random() - 0.5)  // ✅ 随机排序
        .slice(0, count);                  // ✅ 取前N个

    // 🚩 危险代码示例:
    // const winners = predefinedList[round];  // ❌ 预设名单
    // const winners = users.filter(u => u.level === 'VIP');  // ❌ 特权

    return winners;
}

// ✅ 推荐:使用密码学安全的随机数
const crypto = require('crypto');
function secureRandom(max) {
    return crypto.randomInt(0, max);
}

💬 给 HR 和技术同学的建议

给 HR 的建议:

  1. 提前公示规则

    • 明确告知抽奖算法(如"使用系统随机数生成器")
    • 说明中奖概率和奖品分配
    • 承诺抽奖过程公开透明
  2. 现场保证公平

    • 邀请员工代表监督
    • 全程录屏(可选)
    • 当场公布完整中奖名单
  3. 技术选型

    • 选择开源的抽奖系统(代码可审查)
    • 使用第三方公证服务
    • 保留抽奖日志供事后查询

给技术同学的建议:

  1. 实现时注意

    // ✅ 好的实现
    - 使用 crypto.randomBytes() 生成随机数
    - 后端控制抽奖逻辑
    - WebSocket 广播确保一致性
    - 记录详细日志
    
    // ❌ 不好的实现
    - 前端 Math.random()(可预测)
    - 预设中奖名单
    - 允许手动干预
    
  2. 防止被质疑

    // 添加抽奖日志
    await db.lotteryLogs.insert({
        timestamp: Date.now(),
        prizeType: prizeType,
        totalUsers: availableUsers.length,
        randomSeed: randomSeed,  // 随机种子
        winners: winners,
        operator: req.user.id
    });
    
    // 提供验证接口
    app.get('/api/lottery/verify/:logId', (req, res) => {
        // 允许员工验证某次抽奖的真实性
    });
    
  3. 开源方案

    • 推荐使用成熟的开源项目
    • 提交代码到 GitHub 公开审查
    • 让员工参与代码 Review

🎯 最后的真相

经过这次深入研究,我的结论是:

技术上:这套抽奖系统设计合理,没有发现明显的作弊代码。

统计上:多次抽奖数据符合随机分布,各部门中奖比例基本均衡。

但是:由于抽奖逻辑在后端,理论上管理员可以干预结果,这是架构层面无法避免的问题。

解决方案

  1. 使用区块链智能合约实现抽奖(完全去中心化)
  2. 采用可验证随机函数(VRF)生成随机数
  3. 引入第三方公证机构监督抽奖过程

💭 后记

同事A:"所以你研究了两天,结论是系统没问题?"
我:"是的,从技术角度看是公平的。"
同事B:"那为什么我还是没中奖?"
我:"因为概率本来就不高啊!760 人抽 10 个 iPhone,中奖率才 1.3%..."
同事A:"好吧... 那明年的抽奖系统就交给你了!"
我:"没问题!我保证用区块链实现,绝对公平!"
同事们:"你这是想害我们读不懂代码是吧?😂"

虽然没中奖很遗憾,但作为程序员,我收获了:

  • ✅ 一套完整的抽奖系统实现方案
  • ✅ WebSocket 实时通信的实战经验
  • ✅ 3D 转盘效果的 CSS 技巧
  • ✅ 一篇技术博客的素材

说到底,技术人的快乐就是这么朴实无华 😎


💡 总结

本文详细讲解了年会抽奖系统的完整实现方案,涵盖了以下核心内容:

技术要点

  • ✅ 3D转盘效果的CSS实现
  • ✅ WebSocket实时通信机制
  • ✅ 弹幕系统的设计与优化
  • ✅ Excel导入导出功能
  • ✅ 防作弊机制设计
  • ✅ 性能优化方案

架构特点

  • 前后端分离,易于部署
  • WebSocket确保多端同步
  • 后端控制抽奖逻辑,防止作弊
  • 模块化设计,易于扩展

适用场景

  • 公司年会抽奖
  • 活动现场抽奖
  • 直播间抽奖
  • 营销活动抽奖

希望这篇文章能帮助你理解年会抽奖系统的实现原理,并为你的项目提供参考。

如果你也在年会没中奖,不如趁这个机会学习一下技术实现,明年自己搞一个! 🚀

有任何问题欢迎在评论区讨论,也欢迎分享你们公司年会的有趣故事!


🔖 标签

#前端 #WebSocket #3D效果 #抽奖系统 #实时通信 #年会 #Socket.IO


如果觉得本文有帮助,请给个👍点赞支持一下!

❌
❌