简介:这是一套面向Java与前端初学者的全栈抽签系统实战项目,适用于教学演示、活动抽奖开发或Spring Boot+Vue技术栈入门学习。资源完整覆盖后端API、前端交互与数据库设计,解决随机抽取、名单管理、结果展示等典型业务场景。压缩包共69个文件,含34个Java后端逻辑与配置类、11个Vue组件及业务JS脚本、7个XML配置与Mapper映射文件、5个CSS样式及3个HTML页面,另含SQL建表语句、图片与字体资源,整体仅1.24MB,轻量易部署。已有163人学习下载,开发者可直接运行调试,深入理解RESTful接口设计、Vue组件通信、JPA数据库操作及前后端分离架构实践,尤其适合对照源码掌握抽签业务状态流转与权限控制实现细节。
1. 抽签不是随机数生成器,而是状态可追溯、规则可配置、结果可审计的业务系统
很多人第一反应是“抽签不就是 Math.random() 吗”,但真实业务场景里,一次抽奖活动可能涉及百人名单导入、多轮次分组抽取、中奖资格校验、结果导出留痕、后台权限分级,甚至要对接短信通知或微信模板消息。用 Spring Boot + Vue 实现的抽签系统,本质是一个轻量级但结构完整的业务中台:后端负责数据持久化、事务控制与接口契约,前端承担交互逻辑、状态管理与可视化呈现,数据库则承载名单、规则、历史记录三类核心实体。它不是玩具 Demo,而是能直接用于部门团建、教学分组、评优初筛、会议座次分配等低频但高确定性要求的场景。适合刚掌握 Spring Boot 基础 CRUD 和 Vue 组件通信的开发者,通过这个项目打通前后端分离开发全流程——从 MySQL 表设计、MyBatis-Plus 动态查询、Vue Router 路由守卫,到 Axios 请求拦截、Element Plus 表单验证、ECharts 中奖分布图渲染。标题里的“源码+数据库”不是噱头,而是指包含完整 DDL 脚本、预置测试数据、可一键启动的工程结构。
2. 用 Spring Boot 构建可扩展的抽签业务模型:从实体定义到事务边界控制
2.1 核心数据库表设计与字段语义约束
抽签系统的数据模型必须支撑“名单可回溯、规则可复用、结果可重演”三大能力。不能只建一张draw_record表存结果,而应拆分为三张主表并建立明确外键关系:
-- 抽签活动主表(每次发起一个新抽签任务) CREATE TABLE draw_activity ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '活动名称,如"2024年度优秀员工初选"', status TINYINT DEFAULT 1 COMMENT '状态:1-待开始,2-进行中,3-已结束,4-已取消', rule_id BIGINT NOT NULL COMMENT '关联抽签规则ID', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 抽签规则表(支持复用,如"不重复抽取"、"按部门分组抽取") CREATE TABLE draw_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT '规则名称', type VARCHAR(20) NOT NULL COMMENT '规则类型:SINGLE(单次)、MULTI(多轮)、GROUP(分组)', is_repeatable BOOLEAN DEFAULT FALSE COMMENT '是否允许重复中奖', max_count INT DEFAULT 0 COMMENT '最大中奖人数,0表示不限', description TEXT COMMENT '规则说明', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 参与者名单表(支持批量导入,含状态标记) CREATE TABLE draw_participant ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id BIGINT NOT NULL COMMENT '所属活动ID', name VARCHAR(50) NOT NULL, code VARCHAR(30) COMMENT '唯一编码,如工号/学号', department VARCHAR(50) COMMENT '所属部门/班级', status TINYINT DEFAULT 1 COMMENT '状态:1-有效,2-已剔除,3-已中奖', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_activity_status (activity_id, status) );提示:
draw_participant.status字段是关键设计点。它避免了物理删除导致历史不可查,也规避了“已中奖者再次被抽中”的逻辑漏洞。实际抽取时,SQL 查询需显式加上WHERE status = 1,且更新中奖状态必须用UPDATE ... SET status = 3 WHERE id = ? AND status = 1防止并发重复更新。
2.2 Spring Boot 层级划分与关键注解使用
项目采用标准分层:controller → service → mapper → entity。重点在于 Service 层对事务和并发的处理:
@Service public class DrawService { @Autowired private DrawActivityMapper activityMapper; @Autowired private DrawParticipantMapper participantMapper; // @Transactional 是必须的:确保“查可用名单→随机取→更新状态→写结果”原子性 @Transactional(rollbackFor = Exception.class) public DrawResultDTO executeDraw(Long activityId) { // 1. 校验活动状态 DrawActivity activity = activityMapper.selectById(activityId); if (!Objects.equals(activity.getStatus(), 2)) { throw new BusinessException("活动未开始或已结束"); } // 2. 获取当前可用参与者(status=1) List<DrawParticipant> candidates = participantMapper.selectList( new QueryWrapper<DrawParticipant>() .eq("activity_id", activityId) .eq("status", 1) ); if (candidates.isEmpty()) { throw new BusinessException("无可抽取人员"); } // 3. 随机抽取(注意:不是 Math.random(),而是 SecureRandom 更安全) SecureRandom random = new SecureRandom(); int index = random.nextInt(candidates.size()); DrawParticipant winner = candidates.get(index); // 4. 更新中奖状态(带乐观锁条件) int updated = participantMapper.update( new DrawParticipant().setStatus((byte) 3), new UpdateWrapper<DrawParticipant>() .eq("id", winner.getId()) .eq("status", 1) // 确保只更新未中奖状态 ); if (updated == 0) { throw new BusinessException("并发冲突:该人员已被他人抽中"); } // 5. 记录抽取结果(可选:写入 draw_result 表) DrawResult result = new DrawResult(); result.setActivityId(activityId); result.setParticipantId(winner.getId()); result.setWinnerName(winner.getName()); result.setCreatedAt(new Date()); drawResultMapper.insert(result); return new DrawResultDTO(winner.getName(), winner.getCode()); } }2.2.1 为什么用SecureRandom而非Math.random()?
Math.random()基于线性同余生成器(LCG),周期短、可预测,不适合业务场景;SecureRandom使用操作系统熵池(Linux 下为/dev/urandom),满足密码学安全强度。Spring Boot 默认已集成,无需额外依赖。
2.2.2 并发控制的两种实现路径对比
| 方式 | 实现要点 | 适用场景 | 缺点 |
|---|---|---|---|
| 乐观锁(推荐) | 在UPDATE语句中加WHERE status = 1条件,检查影响行数 | 中奖率低、并发量中等(<100 QPS) | 高并发下失败重试次数增加 |
| Redis 分布式锁 | SET draw:lock:{activityId} 1 EX 10 NX+ Lua 脚本释放 | 活动热度极高、需强一致性 | 引入 Redis 依赖,运维成本上升 |
本项目默认采用乐观锁,因其零依赖、易理解、符合 ACID 原则。若后续需支持万人级团建抽签,再平滑升级为 Redis 锁。
3. Vue 前端实现动态交互与状态驱动:从名单上传到结果可视化
3.1 基于 Element Plus 的抽签流程组件化拆分
整个抽签界面不应是一个巨型单文件组件,而应拆分为可复用、可测试的子模块:
DrawUpload.vue:支持 Excel 文件拖拽上传、解析预览、错误行高亮DrawConfig.vue:规则选择器(单抽/多轮/分组)、中奖人数输入、部门筛选下拉DrawPanel.vue:实时显示剩余人数、已抽中名单、倒计时按钮(防误触)DrawResultChart.vue:ECharts 渲染中奖部门分布饼图(需后端提供/api/draw/result/statistics接口)
每个组件通过v-model或defineEmits与父组件DrawPage.vue通信,避免状态污染。
3.2 Excel 名单解析与后端校验协同策略
前端上传 Excel 时,不能仅做格式校验(如列名是否为“姓名、工号、部门”),必须与后端二次校验联动:
// DrawUpload.vue 中的上传逻辑 const handleUpload = async (file) => { const formData = new FormData(); formData.append('file', file); try { const res = await axios.post('/api/draw/participant/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); if (res.data.code === 200) { // 后端返回解析结果:成功条数、失败行号及原因 const { successCount, failedRows } = res.data.data; ElMessage.success(`成功导入 ${successCount} 条`); if (failedRows.length > 0) { // 显示表格展示失败明细,支持导出错误报告 showFailedTable(failedRows); } loadParticipantList(); // 刷新名单列表 } } catch (e) { ElMessage.error('上传失败:' + e.response?.data?.message || '网络错误'); } };注意:后端
/api/draw/participant/upload接口需使用@RequestParam MultipartFile file接收,并用 Apache POI 解析。关键点在于——前端只做轻量级格式提示(如“第5行工号为空”),所有业务规则校验(如“工号重复”、“部门不存在”)必须由后端执行并返回结构化错误信息。这是防止绕过前端校验的根本防线。
3.3 抽签按钮的防抖与状态锁定机制
用户点击“开始抽签”按钮时,必须阻断重复提交,且需体现操作过程:
<template> <el-button :loading="isDrawing" :disabled="!canDraw" @click="startDraw" > {{ isDrawing ? '正在抽取...' : '开始抽签' }} </el-button> </template> <script setup> import { ref, computed } from 'vue' import { ElMessage } from 'element-plus' const isDrawing = ref(false) const participantList = ref([]) // 已加载的参与者列表 // 计算是否可抽:有可用人员且活动状态为“进行中” const canDraw = computed(() => { return participantList.value.some(p => p.status === 1) }) const startDraw = async () => { if (isDrawing.value || !canDraw.value) return isDrawing.value = true try { const res = await axios.post('/api/draw/execute', { activityId: currentActivityId.value }) ElMessage.success(`恭喜 ${res.data.winnerName} 中奖!`) // 触发列表刷新,中奖者 status 变为 3 loadParticipantList() } catch (e) { ElMessage.error(e.response?.data?.message || '抽签失败') } finally { isDrawing.value = false } } </script>3.3.1 为什么不用lodash.debounce?
debounce适用于搜索框输入防抖,但抽签是一次性事务操作。用户点击后必须立即响应(哪怕失败),而非延迟执行。此处isDrawing的作用是 UI 层状态锁,配合后端事务回滚,共同构成“一次点击,一次生效”的用户体验。
4. 数据库初始化与环境隔离:本地开发、测试、生产三套配置落地
4.1 使用 Flyway 实现数据库版本化迁移
手动执行 SQL 脚本易出错、难追溯。本项目集成 Flyway,将建表语句、初始数据、索引优化封装为可版本管理的迁移脚本:
src/main/resources/db/migration/ ├── V1__create_tables.sql # 创建 draw_activity, draw_rule, draw_participant ├── V2__add_index_to_participant.sql # 添加 activity_id + status 复合索引 └── V3__insert_default_rules.sql # 插入默认规则:单次抽取、不可重复在application.yml中启用:
spring: flyway: enabled: true locations: classpath:db/migration baseline-on-migrate: true # 首次运行时自动 baseline启动应用时,Flyway 自动检测未执行的脚本并按序执行,确保不同环境数据库结构一致。开发人员只需关注V{version}__desc.sql文件内容,无需记忆 SQL 执行顺序。
4.2 多环境配置与敏感信息隔离
application-dev.yml、application-prod.yml必须严格区分:
# application-dev.yml(开发环境) spring: datasource: url: jdbc:mysql://localhost:3306/draw_dev?useSSL=false&serverTimezone=Asia/Shanghai username: dev_user password: dev_pass # application-prod.yml(生产环境) spring: datasource: url: ${DB_URL:jdbc:mysql://prod-db:3306/draw_prod?...} username: ${DB_USER:prod_user} password: ${DB_PASS:prod_pass}提示:生产环境的数据库密码绝不能硬编码在配置文件中,必须通过环境变量(如 Docker 的
-e DB_PASS=xxx)或 Kubernetes Secret 注入。Spring Boot 会优先读取环境变量值,覆盖配置文件默认值。
4.3 初始化测试数据的两种方式
| 方式 | 实现 | 适用阶段 |
|---|---|---|
| Flyway V3 脚本 | INSERT INTO draw_rule (...) VALUES (...); | 所有环境统一,用于基础规则 |
Spring Boot@PostConstruct | 在DrawInitService中判断spring.profiles.active=dev后插入 10 条测试名单 | 仅开发环境,避免污染测试/生产数据 |
示例代码:
@Component @Profile("dev") public class DrawInitService { @Autowired private DrawParticipantMapper participantMapper; @PostConstruct public void initTestData() { if (participantMapper.selectCount(null) == 0) { List<DrawParticipant> testData = IntStream.range(1, 11) .mapToObj(i -> new DrawParticipant() .setName("测试人员" + i) .setCode("TEST" + String.format("%03d", i)) .setDepartment("技术部") .setActivityId(1L) ).collect(Collectors.toList()); participantMapper.insertBatchSomeColumn(testData); } } }5. 抽签结果的可审计性保障:日志记录、操作留痕与导出合规
5.1 关键操作全链路日志埋点
抽签不是匿名行为,每一次抽取都必须留下可追溯的操作痕迹。Spring Boot 日志需记录三要素:谁(操作人)、何时(时间戳)、做了什么(参数+结果):
@Slf4j @Service public class DrawService { @Transactional public DrawResultDTO executeDraw(Long activityId, Long operatorId) { // ... 抽签核心逻辑 ... // 记录审计日志(异步,避免阻塞主流程) CompletableFuture.runAsync(() -> { AuditLog log = new AuditLog(); log.setOperatorId(operatorId); log.setOperationType("DRAW_EXECUTE"); log.setTargetId(activityId); log.setDetail(String.format("抽中人员:%s(%s)", winner.getName(), winner.getCode())); log.setCreatedAt(new Date()); auditLogMapper.insert(log); }); return resultDTO; } }对应audit_log表结构:
CREATE TABLE audit_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, operator_id BIGINT NOT NULL COMMENT '操作人ID', operation_type VARCHAR(30) NOT NULL COMMENT '操作类型:DRAW_EXECUTE, ACTIVITY_START, PARTICIPANT_IMPORT', target_id BIGINT COMMENT '目标ID(如 activity_id)', detail TEXT COMMENT '操作详情(JSON 或文本)', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_operator_time (operator_id, created_at) );5.2 结果导出功能的性能与安全边界
导出 Excel 时,数据量过大(如万人名单)会导致内存溢出或超时。必须做两层防护:
- 后端分页导出:不查全量,而是按
activity_id分批查询(每页 1000 条); - 前端限流提示:导出前校验总记录数,超过 5000 条弹窗确认。
@GetMapping("/export") public void exportResults(@RequestParam Long activityId, HttpServletResponse response) throws IOException { // 1. 校验活动存在且已结束 DrawActivity activity = activityMapper.selectById(activityId); if (!Objects.equals(activity.getStatus(), 3)) { throw new BusinessException("仅支持导出已结束活动的结果"); } // 2. 获取总记录数(避免全表扫描) long totalCount = drawResultMapper.selectCount( new QueryWrapper<DrawResult>().eq("activity_id", activityId) ); if (totalCount > 10000) { throw new BusinessException("结果数量超过 10000 条,请联系管理员导出"); } // 3. 使用 SXSSFWorkbook 流式写入(避免 OOM) SXSSFWorkbook workbook = new SXSSFWorkbook(1000); Sheet sheet = workbook.createSheet("抽签结果"); // ... 写入表头与数据行 ... response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=draw_result_" + activityId + ".xlsx"); workbook.write(response.getOutputStream()); workbook.dispose(); // 必须调用,释放临时文件 }5.2.1SXSSFWorkbook与XSSFWorkbook的关键区别
| 特性 | XSSFWorkbook | SXSSFWorkbook |
|---|---|---|
| 内存占用 | 全量加载到内存,大数据量易 OOM | 仅保留指定行数(如1000)在内存,其余刷盘 |
| 导出速度 | 快(纯内存) | 略慢(涉及磁盘IO) |
| 适用场景 | 小数据量(<1000行) | 大数据量(>1000行) |
本项目默认使用SXSSFWorkbook,因抽签结果导出属于低频但可能大数据量的操作,稳定性优先于极致速度。
5.3 前端导出按钮的权限与状态联动
并非所有用户都能导出。需结合 Vue Router 元信息与后端接口鉴权:
<!-- DrawPanel.vue --> <el-button v-if="hasPermission('draw:export')" @click="exportResults" :disabled="exporting" > {{ exporting ? '导出中...' : '导出结果' }} </el-button>权限判断逻辑:
// utils/permission.js export function hasPermission(permission) { const permissions = localStorage.getItem('user_permissions') || '[]' return JSON.parse(permissions).includes(permission) } // 路由定义中声明所需权限 const routes = [ { path: '/draw', component: () => import('@/views/DrawPage.vue'), meta: { permissions: ['draw:view', 'draw:execute', 'draw:export'] } } ]后端接口/api/draw/export同样需校验 JWT Token 中的permissions字段,实现前后端双重校验。这是防止前端篡改权限标识的根本手段。
本文还有配套的精品资源,点击获取