SpringBoot实现公正有趣好玩的年会抽奖系统

0 阅读9分钟

SpringBoot实现公正有趣好玩的年会抽奖系统

一、项目背景与设计思路

年会抽奖是每个企业年末的重要活动,但传统的抽奖方式往往存在公平性不足、互动性差的问题。本文将基于SpringBoot框架,设计并实现一个公正、有趣、好玩的年会抽奖系统,确保抽奖过程的透明性和随机性,同时增加多种互动功能。

系统核心设计原则:

  1. 公正性:使用加密随机算法,确保结果不可预测
  2. 趣味性:多种抽奖模式,动画效果,实时互动
  3. 可靠性:高并发支持,数据持久化,操作日志
  4. 易用性:简洁的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 多种抽奖模式

  1. 随机抽奖:完全随机,确保公平
  2. 幸运轮盘:视觉冲击力强,增加趣味性
  3. 九宫格抽奖:互动性强,适合小团队
  4. 幸运数字:与员工工号关联,增加参与感

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 扩展功能

  1. 微信小程序接入:员工扫码参与
  2. 人脸识别签到:防止代签
  3. 虚拟礼物系统:员工互送祝福
  4. 抽奖策略引擎:可配置的抽奖规则
  5. 多会场支持:分布式抽奖同步

九、测试与验证

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构建一个公正、有趣、好玩的年会抽奖系统。系统具有以下特点:

  1. 公正性保障:使用加密安全的随机数生成算法,所有操作都有审计日志
  2. 趣味性体验:多种抽奖模式,炫酷的动画效果,实时互动功能
  3. 高可靠性:支持高并发,数据持久化,完善的错误处理机制
  4. 易于扩展:模块化设计,方便添加新功能和抽奖模式
  5. 部署简单:支持Docker容器化部署,一键启动

系统不仅满足了基本的抽奖需求,还通过WebSocket实时通信、多种抽奖模式、数据统计分析等功能,大大提升了年会的互动性和趣味性。企业可以根据自身需求,进一步扩展功能,如集成企业微信、添加更多互动游戏等,打造独一无二的年会抽奖体验。

通过本系统的实施,企业可以确保抽奖活动的公平公正,同时提升员工的参与感和满意度,为年会增添更多欢乐和惊喜。