
简介这是一套面向Java与前端初学者的全栈实战项目源码基于Spring Boot Vue.js构建适用于教学演示、课程设计或小型活动抽签场景帮助开发者掌握前后端分离开发、RESTful接口设计及数据库集成等核心技能。资源共69个文件包含34个Java后端逻辑文件含Controller、Service、Entity等、11个Vue组件与API调用JS脚本、7个XML配置文件如MyBatis映射、5个CSS样式文件及3个SQL建表与初始化脚本整体压缩包仅1.24MB轻量易部署。已有163人学习下载适合快速上手与二次开发。源码结构清晰涵盖完整MVC分层、JWT安全认证、Vuex状态管理、Vue Router路由控制及Docker部署支持附带lucky_draw.sql数据库脚本与pom.xml依赖配置开箱即用便于理解抽签业务逻辑与工程化实践细节。1. 一个能立刻跑起来的抽签系统不是Demo是带数据库、可配置规则、支持并发抽签的真实业务模块你不需要再为年会抽奖写临时脚本也不用在Excel里手动划掉名字——这个基于 Spring Boot Vue.js 的抽签系统从lucky_draw.sql到pom.xml全量开源启动后直接访问/admin就能录入名单、设置中奖概率、执行多轮抽签结果实时推送到前端界面。它不是教学玩具数据库表设计包含participant含姓名/部门/工号/权重、prize等级/数量/是否分组抽取、draw_record抽签批次ID、时间戳、中奖人ID、操作员支持按部门加权抽、同人不连中、中奖后自动冻结等生产级逻辑。后端用 Spring Boot 2.7.x非最新但稳定兼容 JDK8前端用 Vue 2.6 Element UI非 Composition API但结构清晰易改所有接口走 RESTful 风格无 session 状态适合嵌入到现有 OA 或 HR 系统中作为独立微服务模块。如果你正在做企业内部活动系统、培训学员随机分组、或需要快速交付一个「有数据库、能查记录、可审计」的抽签功能这份源码就是开箱即用的最小可行产品MVP。2. 数据库建模与 Spring Boot 持久层实现为什么用 JPA 而非 MyBatis以及如何让加权抽签不偏移2.1lucky_draw.sql的三张核心表设计逻辑与字段含义项目根目录下的db/lucky_draw.sql是整个系统的数据基石。它并非简单的一张用户表一张奖品表而是围绕「可复现、可追溯、可配置」三个目标构建participant表除id,name,created_time外关键字段是weight整型默认1和statustinyint0可用1已中奖2禁用。weight不是概率百分比而是抽签时的“票数”——比如某部门5人每人 weight2则该部门总票数为10占全局票池比例 10 / Σ(weight)。这种设计避免浮点运算误差且支持动态调整。prize表levelvarchar如“一等奖”、quantityint剩余可中奖数、is_groupedtinyint0全局抽取1需按 participant.department 分组抽取。当is_grouped1时后端逻辑会先按 department 聚合 participant再在每组内按 weight 抽取确保各组都有获奖者。draw_record表batch_idvarchar格式为DRAW_20240520_001便于人工识别批次、prize_id、participant_id、operator操作员账号、draw_timedatetime。注意batch_id是业务主键而非自增 ID因为一次抽签可能产生多条记录且需支持回滚重抽。提示lucky_draw.sql中未显式创建外键约束这是有意为之——高并发抽签场景下外键检查会成为性能瓶颈。实际一致性由 Service 层事务 业务校验保障例如在DrawService.draw()方法中先SELECT FOR UPDATE锁定 prize.quantity 和 participant.status再更新。2.2 JPA 实体映射与加权随机算法的 Java 实现Spring Boot 后端使用 Spring Data JPA而非 MyBatis原因在于抽签逻辑高度依赖「聚合计算」和「事务一致性」JPA 的QueryTransactional组合比 XML SQL 更易维护。关键实体类位于src/main/java/com/example/lottery/entity/Entity Table(name participant) public class Participant { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; private String department; private Integer weight 1; // 默认权重为1 private Byte status 0; // 0可用, 1已中奖, 2禁用 // getter/setter 省略 }加权随机抽签的核心方法DrawService.weightedRandomDraw()不采用Math.random()累加法易受 double 精度影响而是用Fisher-Yates 变种 前缀和二分查找// 在 DrawService.java 中 public ListParticipant weightedRandomDraw(Long prizeId, int count) { // 1. 获取当前可用参与者status0及其权重 ListObject[] rawList participantRepository.findAvailableWithWeight(); if (rawList.isEmpty()) throw new RuntimeException(无可用参与者); // 2. 构建前缀和数组[w1, w1w2, w1w2w3, ...] int[] prefixSum new int[rawList.size()]; int totalWeight 0; for (int i 0; i rawList.size(); i) { Integer weight ((Number) rawList.get(i)[1]).intValue(); totalWeight weight; prefixSum[i] totalWeight; } // 3. 生成 count 个 [1, totalWeight] 区间内的随机整数 Random rand new Random(); SetInteger selectedIndices new HashSet(); while (selectedIndices.size() count) { int target rand.nextInt(totalWeight) 1; // 注意1 使范围为 [1, totalWeight] // 4. 二分查找 target 落在哪个前缀和区间 - 对应原始索引 int idx Arrays.binarySearch(prefixSum, target); if (idx 0) idx -idx - 1; // binarySearch 返回负数时的修正 selectedIndices.add(idx); } // 5. 根据索引提取 Participant 对象需重新查库以获取完整实体 ListLong ids selectedIndices.stream() .map(i - ((Number) rawList.get(i)[0]).longValue()) .collect(Collectors.toList()); return participantRepository.findAllById(ids); }2.2.1 为什么不用ThreadLocalRandom.current().nextInt()ThreadLocalRandom在多线程下性能更好但此处rand.nextInt(totalWeight)已足够。真正关键的是target rand.nextInt(totalWeight) 1—— 若不加1nextInt(n)返回[0, n)而前缀和数组最小值是w1≥1会导致第一个元素永远无法被选中。这个细节在lucky-draw.iml的测试用例DrawServiceTest.testWeightedDraw()中已覆盖。2.2.2 JPA Repository 的关键查询方法ParticipantRepository.java定义了两个核心查询Repository public interface ParticipantRepository extends JpaRepositoryParticipant, Long { // 返回 [id, weight] 数组仅查必要字段减少网络传输 Query(SELECT p.id, p.weight FROM Participant p WHERE p.status 0) ListObject[] findAvailableWithWeight(); // 批量更新状态避免 N1 查询 Modifying Query(UPDATE Participant p SET p.status 1 WHERE p.id IN :ids) int updateStatusToDrawn(Param(ids) ListLong ids); }findAvailableWithWeight()使用原生 SQL 而非 JPQL是因为 JPQL 无法返回非实体对象数组updateStatusToDrawn()的Modifying注解必须配合Transactional使用否则抛出InvalidDataAccessResourceUsageException。2.3 并发安全如何防止同一人被重复抽中或奖品超发抽签是典型的「读-改-写」场景必须解决 ABA 问题。本项目采用数据库行锁 应用层乐观锁双保险数据库层在DrawService.draw()方法上添加Transactional(isolation Isolation.REPEATABLE_READ)并在查询 participant 时使用SELECT ... FOR UPDATE通过Query的nativeQuery true实现应用层prize表增加version字段Version注解每次抽取前校验quantity 0且version未变失败则重试最多3次。Transactional(isolation Isolation.REPEATABLE_READ) public DrawResult draw(Long prizeId, int count) { Prize prize prizeRepository.findById(prizeId).orElseThrow(); if (prize.getQuantity() count) { throw new IllegalStateException(奖品剩余数量不足); } // 关键锁定 prize 行防止并发修改 quantity Prize lockedPrize prizeRepository.lockById(prizeId); ListParticipant winners weightedRandomDraw(prizeId, count); // 更新 prize.quantity 和 participant.status lockedPrize.setQuantity(lockedPrize.getQuantity() - count); participantRepository.updateStatusToDrawn(winners.stream().map(Participant::getId).collect(Collectors.toList())); // 记录 draw_record ListDrawRecord records winners.stream().map(p - { DrawRecord r new DrawRecord(); r.setBatchId(generateBatchId()); r.setPrizeId(prizeId); r.setParticipantId(p.getId()); r.setOperator(SecurityContextHolder.getContext().getAuthentication().getName()); return r; }).collect(Collectors.toList()); drawRecordRepository.saveAll(records); return new DrawResult(winners, lockedPrize.getQuantity()); }prizeRepository.lockById()的实现Query(value SELECT * FROM prize WHERE id :id FOR UPDATE, nativeQuery true) Prize lockById(Param(id) Long id);注意MySQL 的REPEATABLE READ隔离级别下SELECT ... FOR UPDATE会锁住匹配的行及其间隙gap lock有效防止幻读。若使用 PostgreSQL需改为SELECT ... FOR UPDATE OF prize。3. Vue.js 前端交互与状态管理如何让抽签过程「看得见、控得住、查得清」3.1 Vue Router 路由配置与权限守卫的实际落地前端路由定义在src/router/index.js采用懒加载component: () import(/views/AdminView.vue)提升首屏速度。关键路由包括/login无守卫纯登录页/admin需登录但无角色限制/admin/draw抽签主界面需hasPermission(DRAW_EXECUTE)/admin/record历史记录页需hasPermission(DRAW_VIEW)。权限守卫不是靠后端返回的roles字段硬编码而是通过动态路由 后端鉴权接口实现// src/utils/permission.js export function hasPermission(permission) { const permissions store.state.user.permissions || []; return permissions.includes(permission); } // src/router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (!token to.path ! /login) { next(/login); } else if (to.matched.some(record record.meta.requiresAuth)) { // 动态获取权限列表仅首次进入时调用 if (!store.state.user.permissions) { api.getPermissions().then(res { store.commit(SET_PERMISSIONS, res.data); if (hasPermission(to.meta.permission)) { next(); } else { next(/403); } }); } else { if (hasPermission(to.meta.permission)) { next(); } else { next(/403); } } } else { next(); } });api.getPermissions()对应后端/api/v1/user/permissions接口返回 JSON 如[DRAW_EXECUTE, PRIZE_MANAGE]。这样做的好处是权限变更无需重启前端且避免前端硬编码角色名如ADMIN/OPERATOR。3.2 抽签组件中的实时反馈与防抖提交src/views/draw/DrawPanel.vue是核心交互组件。它不直接调用axios.post(/api/v1/draw)而是封装为DrawService.executeDraw()内置三重保障请求防抖连续点击「开始抽签」按钮第二次点击会被忽略lodash.debounceLoading 状态绑定使用v-loading指令Element UI覆盖整个抽签区域避免用户重复操作WebSocket 实时推送抽签结果不等接口返回而是通过 WebSocket 主动推送// src/api/draw.js export const drawSocket { init() { this.ws new WebSocket(ws://${location.host}/ws/draw); this.ws.onmessage (event) { const data JSON.parse(event.data); if (data.type DRAW_PROGRESS) { // 更新进度条data.progress 3/10 store.commit(UPDATE_DRAW_PROGRESS, data); } else if (data.type DRAW_COMPLETE) { // 推送最终结果 store.commit(ADD_WINNERS, data.winners); } }; }, execute(prizeId, count) { // 发送指令后端触发 WebSocket 广播 this.ws.send(JSON.stringify({ type: START_DRAW, prizeId, count })); } };后端对应WebSocketController.javaMessageMapping(/draw) SendTo(/topic/draw) public DrawBroadcastMessage handleDrawRequest(DrawRequest request) { DrawResult result drawService.draw(request.getPrizeId(), request.getCount()); return new DrawBroadcastMessage(DRAW_COMPLETE, result.getWinners()); }提示/ws/draw的 WebSocket 配置在WebSocketConfig.java中使用SockJS作为 fallback兼容 IE并启用Stomp协议。若部署在 Nginx 后需在nginx.conf中添加location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; }3.3 Vuex 状态管理的精简设计只存必要状态拒绝过度抽象本项目 Vuex Storesrc/store/index.js仅维护 4 个模块拒绝为每个组件建 namespaceModuleState 字段用途是否持久化usertoken,permissions,userInfo登录态与权限localStoragedrawprogress,winners,batchId抽签过程状态内存抽完即清prizelist,currentPrize奖品列表与选中项localStorage缓存1小时recordhistory,pagination历史记录分页数据内存draw模块的 mutation 示例const mutations { SET_DRAW_PROGRESS(state, { current, total }) { state.progress ${current}/${total}; }, ADD_WINNER(state, winner) { state.winners.push(winner); }, CLEAR_DRAW_STATE(state) { state.progress ; state.winners []; state.batchId ; } };关键点在于CLEAR_DRAW_STATE—— 它在抽签完成后被调用确保下次抽签状态干净。没有SET_DRAW_LOADING这类冗余状态loading 直接由组件v-loading控制。4. 本地运行与生产部署从 IDEA 启动到 Docker 容器化的一键脚本4.1 IDEA 中快速启动后端与前端的实操步骤不要被pom.xml里的spring-boot-maven-plugin和frontend-maven-plugin吓到——本项目已预配置好一键运行方案后端启动打开lottery-system-master根目录在 IDEA 中右键pom.xml→Maven→Reload project确保src/main/resources/application.yml中数据库配置正确spring: datasource: url: jdbc:mysql://localhost:3306/lucky_draw?useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password运行com.example.lottery.LotterySystemApplication主类控制台输出Tomcat started on port(s): 8080即成功。前端启动终端进入lottery-system-master/src/main/frontend目录注意不是项目根目录执行npm install首次→npm run serve浏览器访问http://localhost:8080自动跳转至/login。注意前端vue.config.js中devServer.proxy已配置代理devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } }因此前端代码中所有 API 调用写api/v1/draw即可无需拼接完整 URL。4.2 生产环境 Docker 部署分离前后端镜像复用 MySQL 容器项目未提供Dockerfile但根据pom.xml和package.json可快速构建。推荐前后端分离部署而非单体镜像后端镜像构建Dockerfile.backendFROM openjdk:8-jdk-slim VOLUME /tmp ARG JAR_FILEtarget/lottery-system-0.0.1-SNAPSHOT.jar COPY ${JAR_FILE} app.jar ENTRYPOINT [java,-Djava.security.egdfile:/dev/./urandom,-jar,/app.jar]构建命令cd lottery-system-master mvn clean package -DskipTests docker build -f Dockerfile.backend -t lottery-backend .前端镜像构建Dockerfile.frontendFROM nginx:alpine COPY src/main/frontend/dist/ /usr/share/nginx/html/ COPY nginx.conf /etc/nginx/nginx.conf构建命令cd lottery-system-master/src/main/frontend npm run build cd ../.. docker build -f Dockerfile.frontend -t lottery-frontend .docker-compose.yml 一键启停version: 3.8 services: db: image: mysql:5.7 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: lucky_draw volumes: - ./db/lucky_draw.sql:/docker-entrypoint-initdb.d/init.sql ports: - 3306:3306 backend: image: lottery-backend depends_on: - db environment: SPRING_PROFILES_ACTIVE: prod SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/lucky_draw?useSSLfalseserverTimezoneAsia/Shanghai SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: rootpass ports: - 8080:8080 frontend: image: lottery-frontend depends_on: - backend ports: - 80:80启动命令docker-compose up -d。此时访问http://localhost即可使用完整系统。4.3 数据库初始化与常见连接错误排查lucky_draw.sql在容器启动时自动执行但本地 MySQL 可能因字符集报错。执行前务必确认-- 在 MySQL 客户端中执行 SHOW VARIABLES LIKE character_set%; -- 确保 character_set_database utf8mb4 ALTER DATABASE lucky_draw CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;若启动时报Communications link failure检查MySQL 是否允许远程连接SELECT host FROM mysql.user WHERE userroot;若为localhost需执行UPDATE mysql.user SET host% WHERE userroot; FLUSH PRIVILEGES;application.yml中url的localhost在 Docker 内不可达必须改为db服务名防火墙是否开放 3306 端口Linuxsudo ufw allow 3306。5. 进阶技巧如何将抽签系统嵌入现有 OA并支持微信扫码查中奖5.1 与 Spring Security 整合复用企业统一认证体系本项目默认使用UsernamePasswordAuthenticationFilter但生产环境通常对接 LDAP 或 OAuth2。只需修改SecurityConfig.javaConfiguration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers(/login, /api/v1/public/**).permitAll() .anyRequest().authenticated() .and() .formLogin() .loginPage(/login) // 改为公司 SSO 登录页 URL .defaultSuccessUrl(/admin, true) .and() .logout() .logoutSuccessUrl(https://sso.company.com/logout); // 跳转至统一登出页 } // 若对接 OAuth2注释掉以上启用 // Bean // public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { // return http.authorizeExchange() // .pathMatchers(/login, /api/v1/public/**).permitAll() // .anyExchange().authenticated() // .and().oauth2Login().and().build(); // } }关键点loginPage(/login)不是本系统页面而是重定向到企业 SSO 地址如https://sso.company.com/auth?redirect_urihttp://your-app.com/login/callback后端需实现/login/callback接收授权码并换取 token。5.2 微信扫码查中奖生成带参数二维码与免登录接口为支持年会现场扫码查结果需提供免登录的公开接口。在DrawRecordController.java中新增GetMapping(/public/record/{batchId}) public ResponseEntityListPublicRecordDTO getPublicRecords(PathVariable String batchId) { ListDrawRecord records drawRecordRepository.findByBatchId(batchId); ListPublicRecordDTO dtos records.stream().map(r - { Participant p participantRepository.findById(r.getParticipantId()).orElse(null); return new PublicRecordDTO(p ! null ? p.getName() : 未知, r.getPrize().getLevel()); }).collect(Collectors.toList()); return ResponseEntity.ok(dtos); }前端生成二维码src/utils/qrcode.jsimport QRCode from qrcode.js; export function generateBatchQr(batchId) { const url https://your-domain.com/public/record/${batchId}; return QRCode.toDataURL(url, { width: 200 }); // 返回 base64 图片 }部署时Nginx 需放行/public/**路径location /public/ { proxy_pass http://backend; proxy_set_header Host $host; }提示PublicRecordDTO不返回 participant.id 或 phone仅姓名奖项等级符合 GDPR 要求。若需精确到人可增加verifyCode字段6位随机数扫码后输入验证码才显示全名。5.3 抽签结果导出 ExcelApache POI 与流式响应实践DrawRecordController.exportRecords()方法实现零内存导出避免大文件 OOMGetMapping(/export/{batchId}) public void exportRecords(PathVariable String batchId, HttpServletResponse response) throws IOException { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamedraw_result_ batchId .xlsx); try (SXSSFWorkbook workbook new SXSSFWorkbook(100); // 每100行flush到磁盘 ServletOutputStream out response.getOutputStream()) { Sheet sheet workbook.createSheet(抽签结果); Row header sheet.createRow(0); String[] titles {序号, 姓名, 部门, 奖项等级, 抽中时间}; for (int i 0; i titles.length; i) { Cell cell header.createCell(i); cell.setCellValue(titles[i]); } ListDrawRecord records drawRecordRepository.findByBatchId(batchId); for (int i 0; i records.size(); i) { Row row sheet.createRow(i 1); DrawRecord r records.get(i); Participant p participantRepository.findById(r.getParticipantId()).orElse(null); row.createCell(0).setCellValue(i 1); row.createCell(1).setCellValue(p ! null ? p.getName() : ); row.createCell(2).setCellValue(p ! null ? p.getDepartment() : ); row.createCell(3).setCellValue(r.getPrize().getLevel()); row.createCell(4).setCellValue(r.getDrawTime().toString()); } workbook.write(out); } }前端调用window.location.href /api/v1/export/ batchId浏览器自动触发下载。本文还有配套的精品资源点击获取