SpringBoot实现公正有趣好玩的年会抽奖系统
一、项目背景与设计思路
年会抽奖是每个企业年末的重要活动,但传统的抽奖方式往往存在公平性不足、互动性差的问题。本文将基于SpringBoot框架,设计并实现一个公正、有趣、好玩的年会抽奖系统,确保抽奖过程的透明性和随机性,同时增加多种互动功能。
系统核心设计原则:
- 公正性:使用加密随机算法,确保结果不可预测
- 趣味性:多种抽奖模式,动画效果,实时互动
- 可靠性:高并发支持,数据持久化,操作日志
- 易用性:简洁的Web界面,易于部署和管理
二、技术栈选型
- 后端框架:SpringBoot 2.7.x
- 前端技术:Thymeleaf + Bootstrap 5 + jQuery
- 数据库:MySQL 8.0 + Redis 7.0
- 实时通信:WebSocket
- 安全框架:Spring Security
- 构建工具:Maven 3.8+
三、项目结构设计
lottery-system/
├── src/main/java/com/lottery/
│ ├── config/ # 配置类
│ ├── controller/ # 控制器层
│ ├── service/ # 业务逻辑层
│ ├── repository/ # 数据访问层
│ ├── model/ # 实体类
│ ├── utils/ # 工具类
│ ├── aspect/ # AOP切面
│ └── security/ # 安全配置
├── src/main/resources/
│ ├── static/ # 静态资源
│ └── templates/ # 模板文件
└── pom.xml
四、数据库设计
4.1 核心表结构
-- 员工表
CREATE TABLE employee (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
employee_id VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
department VARCHAR(100),
avatar_url VARCHAR(500),
is_attended BOOLEAN DEFAULT false,
created_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 奖品表
CREATE TABLE prize (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(200) NOT NULL,
level INT NOT NULL COMMENT '奖品等级:1-特等奖,2-一等奖,3-二等奖...',
total_count INT NOT NULL,
remain_count INT NOT NULL,
image_url VARCHAR(500),
description TEXT,
created_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 中奖记录表
CREATE TABLE winning_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
employee_id VARCHAR(50) NOT NULL,
prize_id BIGINT NOT NULL,
draw_time DATETIME DEFAULT CURRENT_TIMESTAMP,
draw_method VARCHAR(50) COMMENT '抽奖方式',
FOREIGN KEY (employee_id) REFERENCES employee(employee_id),
FOREIGN KEY (prize_id) REFERENCES prize(id)
);
-- 抽奖活动表
CREATE TABLE lottery_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(200) NOT NULL,
start_time DATETIME,
end_time DATETIME,
status VARCHAR(20) DEFAULT 'NOT_STARTED',
settings JSON COMMENT '活动配置'
);
五、核心功能实现
5.1 项目依赖配置
<!-- pom.xml -->
<dependencies>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- 数据库 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
<!-- JSON处理 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
5.2 员工实体类
@Entity
@Table(name = "employee")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "employee_id", unique = true, nullable = false)
private String employeeId;
@Column(nullable = false)
private String name;
private String department;
private String avatarUrl;
@Column(name = "is_attended")
private Boolean isAttended = false;
@CreationTimestamp
@Column(name = "created_time", updatable = false)
private LocalDateTime createdTime;
@Transient
private Boolean isWinner = false;
}
5.3 抽奖服务核心实现
@Service
@Slf4j
public class LotteryService {
@Autowired
private EmployeeRepository employeeRepository;
@Autowired
private PrizeRepository prizeRepository;
@Autowired
private WinningRecordRepository winningRecordRepository;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 核心抽奖算法 - 使用加密安全的随机数
*/
public LotteryResult drawPrize(Long prizeId, String drawMethod) {
Prize prize = prizeRepository.findById(prizeId)
.orElseThrow(() -> new RuntimeException("奖品不存在"));
if (prize.getRemainCount() <= 0) {
throw new RuntimeException("该奖品已抽完");
}
// 获取所有未中奖的参会员工
List<Employee> candidates = employeeRepository.findByIsAttendedTrueAndIsWinnerFalse();
if (candidates.isEmpty()) {
throw new RuntimeException("没有符合条件的抽奖人员");
}
// 使用SecureRandom确保随机性
SecureRandom secureRandom = new SecureRandom();
byte[] randomBytes = new byte[32];
secureRandom.nextBytes(randomBytes);
// 基于哈希值选择中奖者
int index = Math.abs(Arrays.hashCode(randomBytes)) % candidates.size();
Employee winner = candidates.get(index);
// 保存中奖记录
WinningRecord record = new WinningRecord();
record.setEmployeeId(winner.getEmployeeId());
record.setPrizeId(prizeId);
record.setDrawMethod(drawMethod);
record.setDrawTime(LocalDateTime.now());
winningRecordRepository.save(record);
// 更新奖品剩余数量
prize.setRemainCount(prize.getRemainCount() - 1);
prizeRepository.save(prize);
// 标记员工为中奖状态(实际中可能需要更复杂的逻辑)
markEmployeeAsWinner(winner.getId());
// 发送WebSocket通知
sendWinningNotification(winner, prize);
return LotteryResult.builder()
.winner(winner)
.prize(prize)
.drawTime(LocalDateTime.now())
.drawId(record.getId())
.build();
}
/**
* 批量导入员工
*/
@Transactional
public void importEmployees(MultipartFile file) {
try {
List<Employee> employees = parseEmployeeFile(file);
employeeRepository.saveAll(employees);
log.info("成功导入{}名员工", employees.size());
} catch (Exception e) {
log.error("导入员工失败", e);
throw new RuntimeException("导入失败: " + e.getMessage());
}
}
/**
* 多种抽奖模式
*/
public LotteryResult drawWithMode(Prize prize, DrawMode mode) {
switch (mode) {
case RANDOM:
return randomDraw(prize);
case LUCKY_NUMBER:
return luckyNumberDraw(prize);
case ROULETTE:
return rouletteDraw(prize);
case GRID:
return gridDraw(prize);
default:
return randomDraw(prize);
}
}
/**
* 轮盘抽奖算法
*/
private LotteryResult rouletteDraw(Prize prize) {
List<Employee> candidates = getValidCandidates();
// 为每个候选人分配权重(这里简单按部门分配不同权重)
Map<Employee, Double> weights = new HashMap<>();
for (Employee emp : candidates) {
double weight = getDepartmentWeight(emp.getDepartment());
weights.put(emp, weight);
}
// 轮盘选择
Employee winner = rouletteSelect(weights);
return createLotteryResult(winner, prize, "ROULETTE");
}
private Employee rouletteSelect(Map<Employee, Double> weights) {
double totalWeight = weights.values().stream().mapToDouble(Double::doubleValue).sum();
double random = Math.random() * totalWeight;
double cumulativeWeight = 0.0;
for (Map.Entry<Employee, Double> entry : weights.entrySet()) {
cumulativeWeight += entry.getValue();
if (random <= cumulativeWeight) {
return entry.getKey();
}
}
return weights.keySet().iterator().next();
}
}
5.4 WebSocket实时通信
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(lotteryWebSocketHandler(), "/ws/lottery")
.setAllowedOrigins("*")
.withSockJS();
}
@Bean
public WebSocketHandler lotteryWebSocketHandler() {
return new LotteryWebSocketHandler();
}
}
@Component
public class LotteryWebSocketHandler extends TextWebSocketHandler {
private static final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
String sessionId = session.getId();
sessions.put(sessionId, session);
// 发送欢迎消息
sendMessage(session, "欢迎加入年会抽奖!");
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
// 处理客户端消息
String payload = message.getPayload();
// 处理业务逻辑...
}
/**
* 广播中奖消息
*/
public void broadcastWinningMessage(Employee winner, Prize prize) {
Map<String, Object> message = new HashMap<>();
message.put("type", "WINNING_NOTIFICATION");
message.put("winner", winner.getName());
message.put("prize", prize.getName());
message.put("department", winner.getDepartment());
message.put("time", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_TIME));
String jsonMessage = JSON.toJSONString(message);
sessions.values().forEach(session -> {
try {
if (session.isOpen()) {
session.sendMessage(new TextMessage(jsonMessage));
}
} catch (IOException e) {
log.error("发送WebSocket消息失败", e);
}
});
}
}
5.5 抽奖控制器
@Controller
@RequestMapping("/lottery")
public class LotteryController {
@Autowired
private LotteryService lotteryService;
@Autowired
private LotteryWebSocketHandler webSocketHandler;
/**
* 抽奖页面
*/
@GetMapping("/draw")
public String drawPage(Model model) {
List<Prize> availablePrizes = lotteryService.getAvailablePrizes();
List<Employee> attendees = lotteryService.getAttendees();
model.addAttribute("prizes", availablePrizes);
model.addAttribute("attendees", attendees);
model.addAttribute("totalAttendees", attendees.size());
return "lottery/draw";
}
/**
* 执行抽奖
*/
@PostMapping("/execute")
@ResponseBody
public ApiResponse<LotteryResult> executeDraw(
@RequestParam Long prizeId,
@RequestParam(defaultValue = "RANDOM") String mode) {
try {
LotteryResult result = lotteryService.drawWithMode(prizeId, mode);
// 发送实时通知
webSocketHandler.broadcastWinningMessage(
result.getWinner(),
result.getPrize()
);
return ApiResponse.success(result);
} catch (Exception e) {
return ApiResponse.error(e.getMessage());
}
}
/**
* 大屏幕显示
*/
@GetMapping("/screen")
public String bigScreen(Model model) {
List<WinningRecord> recentWinners = lotteryService.getRecentWinners(10);
model.addAttribute("winners", recentWinners);
return "lottery/screen";
}
/**
* 数据统计
*/
@GetMapping("/statistics")
@ResponseBody
public ApiResponse<Statistics> getStatistics() {
Statistics stats = lotteryService.getLotteryStatistics();
return ApiResponse.success(stats);
}
}
5.6 前端抽奖页面实现
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>年会抽奖系统</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.lottery-container {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.prize-card {
transition: all 0.3s ease;
cursor: pointer;
}
.prize-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.2);
}
.lottery-wheel {
width: 500px;
height: 500px;
position: relative;
margin: 0 auto;
}
.wheel-canvas {
width: 100%;
height: 100%;
}
.winner-display {
background: rgba(255,255,255,0.9);
border-radius: 15px;
padding: 20px;
animation: slideIn 0.5s ease;
}
@keyframes slideIn {
from { transform: translateY(50px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.confetti {
position: fixed;
width: 15px;
height: 15px;
background-color: #f00;
top: -10px;
opacity: 0;
}
@keyframes confetti-fall {
0% { top: -10px; opacity: 1; }
100% { top: 100vh; opacity: 0; }
}
</style>
</head>
<body>
<div class="lottery-container">
<div class="container">
<!-- 标题区域 -->
<div class="text-center text-white mb-5">
<h1 class="display-3 fw-bold">🎉 年会幸运大抽奖 🎉</h1>
<p class="lead">梦想成真,幸运降临</p>
</div>
<!-- 抽奖主区域 -->
<div class="row">
<!-- 左侧:奖品选择 -->
<div class="col-md-4">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fas fa-gift"></i> 奖品列表</h5>
</div>
<div class="card-body" id="prizeList">
<!-- 奖品列表动态加载 -->
</div>
</div>
<!-- 抽奖控制 -->
<div class="card mt-3">
<div class="card-body">
<div class="mb-3">
<label class="form-label">抽奖模式</label>
<select class="form-select" id="drawMode">
<option value="RANDOM">随机抽奖</option>
<option value="ROULETTE">幸运轮盘</option>
<option value="GRID">九宫格抽奖</option>
<option value="LUCKY_NUMBER">幸运数字</option>
</select>
</div>
<button class="btn btn-danger btn-lg w-100" id="drawButton">
<i class="fas fa-play"></i> 开始抽奖
</button>
</div>
</div>
</div>
<!-- 中间:抽奖动画 -->
<div class="col-md-4">
<div class="lottery-wheel">
<canvas id="wheelCanvas" class="wheel-canvas"></canvas>
<div class="text-center mt-3">
<div class="winner-display" id="winnerDisplay" style="display:none;">
<h4 class="text-success">🎊 恭喜中奖! 🎊</h4>
<h3 id="winnerName" class="fw-bold"></h3>
<p>获得:<span id="prizeName" class="text-primary"></span></p>
</div>
</div>
</div>
</div>
<!-- 右侧:中奖名单 -->
<div class="col-md-4">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="fas fa-trophy"></i> 中奖名单</h5>
</div>
<div class="card-body">
<div id="winnerList" class="list-group">
<!-- 中奖名单动态加载 -->
</div>
</div>
</div>
<!-- 统计数据 -->
<div class="card mt-3">
<div class="card-body">
<h6>抽奖统计</h6>
<div class="row text-center">
<div class="col-6">
<div class="display-6" id="totalAttendees">0</div>
<small>参会人数</small>
</div>
<div class="col-6">
<div class="display-6" id="totalWinners">0</div>
<small>已中奖人数</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- WebSocket连接 -->
<script src="https://cdn.bootcdn.net/ajax/libs/sockjs-client/1.6.1/sockjs.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/bootstrap/5.1.3/js/bootstrap.bundle.min.js"></script>
<script>
class LotterySystem {
constructor() {
this.stompClient = null;
this.currentPrizeId = null;
this.isDrawing = false;
this.init();
}
init() {
this.loadPrizes();
this.loadWinners();
this.loadStatistics();
this.connectWebSocket();
this.bindEvents();
this.initWheel();
}
connectWebSocket() {
const socket = new SockJS('/ws/lottery');
this.stompClient = Stomp.over(socket);
this.stompClient.connect({}, (frame) => {
console.log('Connected: ' + frame);
this.stompClient.subscribe('/topic/winners', (message) => {
const notification = JSON.parse(message.body);
this.showWinningNotification(notification);
this.loadWinners();
this.loadStatistics();
});
});
}
loadPrizes() {
$.get('/lottery/prizes', (prizes) => {
const container = $('#prizeList');
container.empty();
prizes.forEach(prize => {
const card = `
<div class="card prize-card mb-2" data-prize-id="${prize.id}">
<div class="card-body">
<h6 class="card-title">${prize.name}</h6>
<p class="card-text text-muted small">剩余: ${prize.remainCount}/${prize.totalCount}</p>
</div>
</div>
`;
container.append(card);
});
// 绑定选择事件
$('.prize-card').click(function() {
$('.prize-card').removeClass('border-primary');
$(this).addClass('border-primary border-3');
this.currentPrizeId = $(this).data('prize-id');
});
});
}
loadWinners() {
$.get('/lottery/recent-winners', (winners) => {
const container = $('#winnerList');
container.empty();
winners.forEach(record => {
const item = `
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">${record.employeeName}</h6>
<small>${record.department}</small>
</div>
<p class="mb-1">${record.prizeName}</p>
<small class="text-muted">${record.drawTime}</small>
</div>
`;
container.prepend(item);
});
});
}
loadStatistics() {
$.get('/lottery/statistics', (stats) => {
$('#totalAttendees').text(stats.totalAttendees);
$('#totalWinners').text(stats.totalWinners);
});
}
bindEvents() {
$('#drawButton').click(() => {
if (!this.currentPrizeId) {
alert('请先选择奖品!');
return;
}
if (this.isDrawing) {
alert('正在抽奖中,请稍候...');
return;
}
this.isDrawing = true;
$('#drawButton').prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 抽奖中...');
const drawMode = $('#drawMode').val();
// 开始抽奖动画
this.startWheelAnimation();
// 发送抽奖请求
$.ajax({
url: '/lottery/execute',
method: 'POST',
data: {
prizeId: this.currentPrizeId,
mode: drawMode
},
success: (response) => {
if (response.success) {
setTimeout(() => {
this.showWinner(response.data);
this.createConfetti();
this.isDrawing = false;
$('#drawButton').prop('disabled', false).html('<i class="fas fa-play"></i> 开始抽奖');
}, 3000);
} else {
alert(response.message);
this.isDrawing = false;
$('#drawButton').prop('disabled', false).html('<i class="fas fa-play"></i> 开始抽奖');
}
},
error: () => {
alert('抽奖失败,请重试!');
this.isDrawing = false;
$('#drawButton').prop('disabled', false).html('<i class="fas fa-play"></i> 开始抽奖');
}
});
});
}
startWheelAnimation() {
const canvas = document.getElementById('wheelCanvas');
const ctx = canvas.getContext('2d');
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const radius = Math.min(centerX, centerY) - 10;
let rotation = 0;
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制转盘
rotation += 0.1;
// 绘制扇形
const segments = 12;
const segmentAngle = (2 * Math.PI) / segments;
for (let i = 0; i < segments; i++) {
const startAngle = segmentAngle * i + rotation;
const endAngle = segmentAngle * (i + 1) + rotation;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
ctx.closePath();
// 交替颜色
ctx.fillStyle = i % 2 === 0 ? '#FF6B6B' : '#4ECDC4';
ctx.fill();
// 绘制文字
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(startAngle + segmentAngle / 2);
ctx.textAlign = 'right';
ctx.fillStyle = 'white';
ctx.font = 'bold 14px Arial';
ctx.fillText('幸运', radius - 20, 10);
ctx.restore();
}
// 绘制指针
ctx.beginPath();
ctx.moveTo(centerX, centerY - 20);
ctx.lineTo(centerX, centerY - radius + 20);
ctx.lineTo(centerX + 10, centerY - radius);
ctx.lineTo(centerX - 10, centerY - radius);
ctx.closePath();
ctx.fillStyle = '#FFD93D';
ctx.fill();
if (this.isDrawing) {
requestAnimationFrame(animate);
}
};
canvas.width = 500;
canvas.height = 500;
animate();
}
showWinner(result) {
$('#winnerName').text(result.winner.name);
$('#prizeName').text(result.prize.name);
$('#winnerDisplay').fadeIn();
// 播放音效
this.playSound('winning');
}
showWinningNotification(notification) {
// 显示实时通知
const notificationHtml = `
<div class="toast show" role="alert">
<div class="toast-header bg-success text-white">
<strong class="me-auto">🎉 恭喜中奖!</strong>
<small>刚刚</small>
</div>
<div class="toast-body">
<strong>${notification.winner}</strong> 获得 <strong>${notification.prize}</strong>
</div>
</div>
`;
$('.toast-container').remove();
$('body').append(`<div class="toast-container position-fixed top-0 end-0 p-3">${notificationHtml}</div>`);
// 5秒后自动移除
setTimeout(() => {
$('.toast-container').remove();
}, 5000);
}
createConfetti() {
const colors = ['#FF6B6B', '#4ECDC4', '#FFD93D', '#6BCF7F'];
for (let i = 0; i < 100; i++) {
const confetti = document.createElement('div');
confetti.className = 'confetti';
confetti.style.left = Math.random() * 100 + 'vw';
confetti.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
confetti.style.width = Math.random() * 10 + 5 + 'px';
confetti.style.height = Math.random() * 10 + 5 + 'px';
confetti.style.animation = `confetti-fall ${Math.random() * 3 + 2}s linear forwards`;
confetti.style.animationDelay = Math.random() * 1 + 's';
document.body.appendChild(confetti);
setTimeout(() => {
confetti.remove();
}, 5000);
}
}
playSound(type) {
const audio = new Audio();
if (type === 'winning') {
audio.src = '/sound/winning.mp3';
} else if (type === 'draw') {
audio.src = '/sound/draw.mp3';
}
audio.play().catch(e => console.log('音频播放失败:', e));
}
initWheel() {
// 初始化抽奖转盘
const canvas = document.getElementById('wheelCanvas');
if (canvas.getContext) {
// 已经在上面的startWheelAnimation中初始化了
}
}
}
// 页面加载完成后初始化
$(document).ready(() => {
window.lotterySystem = new LotterySystem();
});
</script>
</body>
</html>
六、系统特色功能
6.1 多种抽奖模式
- 随机抽奖:完全随机,确保公平
- 幸运轮盘:视觉冲击力强,增加趣味性
- 九宫格抽奖:互动性强,适合小团队
- 幸运数字:与员工工号关联,增加参与感
6.2 实时互动功能
- WebSocket实时通知:中奖结果实时推送到所有客户端
- 大屏幕显示:专门为中奖展示设计的全屏界面
- 弹幕互动:员工可以发送祝福弹幕
- 倒计时功能:增加紧张氛围
6.3 防作弊机制
@Component
public class LotterySecurityService {
/**
* 验证抽奖请求的合法性
*/
public boolean validateDrawRequest(String sessionId, Long prizeId) {
// 防止频繁请求
String key = "draw:limit:" + sessionId;
Long count = redisTemplate.opsForValue().increment(key, 1);
if (count == 1) {
redisTemplate.expire(key, 10, TimeUnit.SECONDS);
}
return count <= 3; // 10秒内最多请求3次
}
/**
* 验证员工资格
*/
public boolean validateEmployeeEligibility(String employeeId) {
// 检查是否已签到
// 检查是否已中奖(限制每人最多中奖次数)
// 检查是否符合特定奖品要求
return true;
}
/**
* 生成抽奖过程审计日志
*/
public void auditDrawProcess(LotteryResult result, String operator) {
String auditLog = String.format(
"抽奖审计 - 操作人:%s, 奖品:%s, 中奖人:%s, 时间:%s, 随机种子:%s",
operator,
result.getPrize().getName(),
result.getWinner().getName(),
LocalDateTime.now(),
generateRandomSeed()
);
log.info(auditLog);
saveToBlockchain(auditLog); // 可选:保存到区块链确保不可篡改
}
}
6.4 数据统计与分析
@Service
public class StatisticsService {
public LotteryStatistics getStatistics() {
LotteryStatistics stats = new LotteryStatistics();
// 基础统计
stats.setTotalAttendees(employeeRepository.countByIsAttendedTrue());
stats.setTotalWinners(winningRecordRepository.count());
stats.setTotalPrizes(prizeRepository.count());
// 部门中奖分布
Map<String, Long> deptDistribution = winningRecordRepository
.getDepartmentDistribution();
stats.setDepartmentDistribution(deptDistribution);
// 时间段分析
List<HourlyDraw> hourlyDraws = winningRecordRepository
.getHourlyDrawCount();
stats.setHourlyDraws(hourlyDraws);
// 奖品热度
List<PrizePopularity> prizePopularity = winningRecordRepository
.getPrizePopularity();
stats.setPrizePopularity(prizePopularity);
return stats;
}
/**
* 生成抽奖报告
*/
public Report generateReport() {
Report report = new Report();
// 总体情况
report.setSummary(getSummary());
// 详细数据
report.setDetails(getDetailedData());
// 图表数据
report.setCharts(getChartData());
// 分析结论
report.setAnalysis(getAnalysis());
return report;
}
}
七、部署与配置
7.1 应用配置
# application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/lottery_db?useSSL=false&serverTimezone=UTC
username: lottery_admin
password: lottery_password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
redis:
host: localhost
port: 6379
password:
timeout: 5000ms
thymeleaf:
cache: false
mode: HTML
encoding: UTF-8
lottery:
security:
admin-username: admin
admin-password: ${ADMIN_PASSWORD:admin123}
jwt-secret: ${JWT_SECRET:lottery-secret-key}
draw:
max-attempts-per-minute: 10
cooldown-seconds: 5
ui:
title: 年会幸运大抽奖
theme: gradient-blue
animation-enabled: true
7.2 Docker部署
# Dockerfile
FROM openjdk:11-jre-slim
WORKDIR /app
COPY target/lottery-system-1.0.0.jar app.jar
RUN mkdir -p /app/logs /app/uploads
ENV TZ=Asia/Shanghai
ENV JAVA_OPTS="-Xmx512m -Xms256m"
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
# docker-compose.yml
version: '3.8'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: lottery_db
MYSQL_USER: lottery_admin
MYSQL_PASSWORD: lottery_password
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
lottery-app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/lottery_db
SPRING_REDIS_HOST: redis
depends_on:
- mysql
- redis
volumes:
mysql_data:
redis_data:
八、系统优化与扩展
8.1 性能优化
@Service
@Slf4j
public class LotteryOptimizationService {
/**
* 使用Redis缓存热门数据
*/
@Cacheable(value = "prizes", key = "'available'")
public List<Prize> getAvailablePrizes() {
return prizeRepository.findByRemainCountGreaterThan(0);
}
/**
* 批量处理优化
*/
@Transactional
public void batchProcessWinners(List<Long> winnerIds, Long prizeId) {
// 使用批量插入提高性能
List<WinningRecord> records = winnerIds.stream()
.map(employeeId -> {
WinningRecord record = new WinningRecord();
record.setEmployeeId(employeeId.toString());
record.setPrizeId(prizeId);
return record;
})
.collect(Collectors.toList());
winningRecordRepository.saveAll(records);
}
/**
* 异步处理非核心业务
*/
@Async
public void asyncSendNotification(WinningRecord record) {
// 发送邮件通知
emailService.sendWinningEmail(record);
// 发送短信通知
smsService.sendWinningSMS(record);
// 记录日志
auditService.logWinningEvent(record);
}
}
8.2 扩展功能
- 微信小程序接入:员工扫码参与
- 人脸识别签到:防止代签
- 虚拟礼物系统:员工互送祝福
- 抽奖策略引擎:可配置的抽奖规则
- 多会场支持:分布式抽奖同步
九、测试与验证
9.1 单元测试
@SpringBootTest
@AutoConfigureMockMvc
class LotteryServiceTest {
@Autowired
private MockMvc mockMvc;
@Test
void testRandomDraw() {
// 测试随机抽奖的公平性
Map<Long, Integer> winCount = new HashMap<>();
int totalTests = 10000;
for (int i = 0; i < totalTests; i++) {
LotteryResult result = lotteryService.drawPrize(1L, "RANDOM");
Long winnerId = result.getWinner().getId();
winCount.put(winnerId, winCount.getOrDefault(winnerId, 0) + 1);
}
// 验证分布是否均匀
double expectedFrequency = totalTests / 100.0; // 假设有100个候选人
double chiSquare = calculateChiSquare(winCount, expectedFrequency);
assertTrue(chiSquare < 15.0, "抽奖分布不均匀");
}
@Test
void testConcurrentDraw() throws InterruptedException {
// 测试并发抽奖
int threadCount = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
List<Future<LotteryResult>> futures = new ArrayList<>();
for (int i = 0; i < threadCount; i++) {
Future<LotteryResult> future = executor.submit(() -> {
try {
return lotteryService.drawPrize(1L, "RANDOM");
} finally {
latch.countDown();
}
});
futures.add(future);
}
latch.await(10, TimeUnit.SECONDS);
// 验证没有重复中奖
Set<Long> winnerIds = futures.stream()
.map(f -> {
try {
return f.get().getWinner().getId();
} catch (Exception e) {
return null;
}
})
.filter(Objects::nonNull)
.collect(Collectors.toSet());
assertEquals(threadCount, winnerIds.size(), "存在重复中奖情况");
}
}
十、总结
本文详细介绍了如何使用SpringBoot + Java构建一个公正、有趣、好玩的年会抽奖系统。系统具有以下特点:
- 公正性保障:使用加密安全的随机数生成算法,所有操作都有审计日志
- 趣味性体验:多种抽奖模式,炫酷的动画效果,实时互动功能
- 高可靠性:支持高并发,数据持久化,完善的错误处理机制
- 易于扩展:模块化设计,方便添加新功能和抽奖模式
- 部署简单:支持Docker容器化部署,一键启动
系统不仅满足了基本的抽奖需求,还通过WebSocket实时通信、多种抽奖模式、数据统计分析等功能,大大提升了年会的互动性和趣味性。企业可以根据自身需求,进一步扩展功能,如集成企业微信、添加更多互动游戏等,打造独一无二的年会抽奖体验。
通过本系统的实施,企业可以确保抽奖活动的公平公正,同时提升员工的参与感和满意度,为年会增添更多欢乐和惊喜。