年会没中奖?程序员花两天逆向了公司抽奖系统,发现了这些秘密...
🎊 从零实现一个炫酷的年会抽奖系统(含3D转盘+实时弹幕)
"又没中奖,这系统是不是内定的?" "作为程序员,我必须搞清楚真相!"
💬 故事的开始
年会当天,大屏幕上 3D 转盘炫酷地旋转着,所有人都盯着自己的名字,期待着中奖的瞬间...
散会后的工位
![]()
![]()
于是,作为一个不服输的程序员,我开始了为期两天的技术"考古"之旅。
📝 研究成果
经过深入分析抓包、逆向前端代码、推测后端实现,我发现这套系统虽然看起来炫酷,但实现原理并不复杂,涉及的核心技术点包括:
- 🎯 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>
📚 参考资源
开源项目
- 年会抽奖系统 - 完整的年会抽奖解决方案
- lucky-draw - Vue.js实现的抽奖系统
- 年会抽奖小程序 - 微信小程序版本
技术文档
🎭 番外篇:如何判断抽奖是否公平?
经过这次研究,我总结了几个实用的方法,帮助大家判断年会抽奖是否公平:
方法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 的建议:
-
提前公示规则
- 明确告知抽奖算法(如"使用系统随机数生成器")
- 说明中奖概率和奖品分配
- 承诺抽奖过程公开透明
-
现场保证公平
- 邀请员工代表监督
- 全程录屏(可选)
- 当场公布完整中奖名单
-
技术选型
- 选择开源的抽奖系统(代码可审查)
- 使用第三方公证服务
- 保留抽奖日志供事后查询
给技术同学的建议:
-
实现时注意
// ✅ 好的实现 - 使用 crypto.randomBytes() 生成随机数 - 后端控制抽奖逻辑 - WebSocket 广播确保一致性 - 记录详细日志 // ❌ 不好的实现 - 前端 Math.random()(可预测) - 预设中奖名单 - 允许手动干预 -
防止被质疑
// 添加抽奖日志 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) => { // 允许员工验证某次抽奖的真实性 }); -
开源方案
- 推荐使用成熟的开源项目
- 提交代码到 GitHub 公开审查
- 让员工参与代码 Review
🎯 最后的真相
经过这次深入研究,我的结论是:
技术上:这套抽奖系统设计合理,没有发现明显的作弊代码。
统计上:多次抽奖数据符合随机分布,各部门中奖比例基本均衡。
但是:由于抽奖逻辑在后端,理论上管理员可以干预结果,这是架构层面无法避免的问题。
解决方案:
- 使用区块链智能合约实现抽奖(完全去中心化)
- 采用可验证随机函数(VRF)生成随机数
- 引入第三方公证机构监督抽奖过程
💭 后记
同事A:"所以你研究了两天,结论是系统没问题?"
我:"是的,从技术角度看是公平的。"
同事B:"那为什么我还是没中奖?"
我:"因为概率本来就不高啊!760 人抽 10 个 iPhone,中奖率才 1.3%..."
同事A:"好吧... 那明年的抽奖系统就交给你了!"
我:"没问题!我保证用区块链实现,绝对公平!"
同事们:"你这是想害我们读不懂代码是吧?😂"
虽然没中奖很遗憾,但作为程序员,我收获了:
- ✅ 一套完整的抽奖系统实现方案
- ✅ WebSocket 实时通信的实战经验
- ✅ 3D 转盘效果的 CSS 技巧
- ✅ 一篇技术博客的素材
说到底,技术人的快乐就是这么朴实无华 😎
💡 总结
本文详细讲解了年会抽奖系统的完整实现方案,涵盖了以下核心内容:
技术要点
- ✅ 3D转盘效果的CSS实现
- ✅ WebSocket实时通信机制
- ✅ 弹幕系统的设计与优化
- ✅ Excel导入导出功能
- ✅ 防作弊机制设计
- ✅ 性能优化方案
架构特点
- 前后端分离,易于部署
- WebSocket确保多端同步
- 后端控制抽奖逻辑,防止作弊
- 模块化设计,易于扩展
适用场景
- 公司年会抽奖
- 活动现场抽奖
- 直播间抽奖
- 营销活动抽奖
希望这篇文章能帮助你理解年会抽奖系统的实现原理,并为你的项目提供参考。
如果你也在年会没中奖,不如趁这个机会学习一下技术实现,明年自己搞一个! 🚀
有任何问题欢迎在评论区讨论,也欢迎分享你们公司年会的有趣故事!
🔖 标签
#前端 #WebSocket #3D效果 #抽奖系统 #实时通信 #年会 #Socket.IO
如果觉得本文有帮助,请给个👍点赞支持一下!