Spring Boot+Vue抽签系统:支持加权/分组/并发的生产级实现
2026/9/13 15:00:26 网站建设 项目流程

简介:这是一套面向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.sqlpom.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)和status(tinyint,0=可用,1=已中奖,2=禁用)。weight不是概率百分比,而是抽签时的“票数”——比如某部门5人,每人 weight=2,则该部门总票数为10,占全局票池比例 = 10 / Σ(weight)。这种设计避免浮点运算误差,且支持动态调整。
  • prize表:level(varchar,如“一等奖”)、quantity(int,剩余可中奖数)、is_grouped(tinyint,0=全局抽取,1=需按 participant.department 分组抽取)。当is_grouped=1时,后端逻辑会先按 department 聚合 participant,再在每组内按 weight 抽取,确保各组都有获奖者。
  • draw_record表:batch_id(varchar,格式为DRAW_20240520_001,便于人工识别批次)、prize_idparticipant_idoperator(操作员账号)、draw_time(datetime)。注意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 的@Query+@Transactional组合比 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 List<Participant> weightedRandomDraw(Long prizeId, int count) { // 1. 获取当前可用参与者(status=0)及其权重 List<Object[]> rawList = participantRepository.findAvailableWithWeight(); if (rawList.isEmpty()) throw new RuntimeException("无可用参与者"); // 2. 构建前缀和数组:[w1, w1+w2, w1+w2+w3, ...] 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(); Set<Integer> 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 对象(需重新查库以获取完整实体) List<Long> 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—— 若不加1,nextInt(n)返回[0, n),而前缀和数组最小值是w1(≥1),会导致第一个元素永远无法被选中。这个细节在lucky-draw.iml的测试用例DrawServiceTest.testWeightedDraw()中已覆盖。

2.2.2 JPA Repository 的关键查询方法

ParticipantRepository.java定义了两个核心查询:

@Repository public interface ParticipantRepository extends JpaRepository<Participant, Long> { // 返回 [id, weight] 数组,仅查必要字段,减少网络传输 @Query("SELECT p.id, p.weight FROM Participant p WHERE p.status = 0") List<Object[]> findAvailableWithWeight(); // 批量更新状态,避免 N+1 查询 @Modifying @Query("UPDATE Participant p SET p.status = 1 WHERE p.id IN :ids") int updateStatusToDrawn(@Param("ids") List<Long> ids); }

findAvailableWithWeight()使用原生 SQL 而非 JPQL,是因为 JPQL 无法返回非实体对象数组;updateStatusToDrawn()@Modifying注解必须配合@Transactional使用,否则抛出InvalidDataAccessResourceUsageException

2.3 并发安全:如何防止同一人被重复抽中或奖品超发

抽签是典型的「读-改-写」场景,必须解决 ABA 问题。本项目采用数据库行锁 + 应用层乐观锁双保险:

  1. 数据库层:在DrawService.draw()方法上添加@Transactional(isolation = Isolation.REPEATABLE_READ),并在查询 participant 时使用SELECT ... FOR UPDATE(通过@QuerynativeQuery = true实现);
  2. 应用层prize表增加version字段(@Version注解),每次抽取前校验quantity > 0version未变,失败则重试(最多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); List<Participant> 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 List<DrawRecord> 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(),内置三重保障:

  1. 请求防抖:连续点击「开始抽签」按钮,第二次点击会被忽略(lodash.debounce);
  2. Loading 状态绑定:使用v-loading指令(Element UI)覆盖整个抽签区域,避免用户重复操作;
  3. 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.java

@MessageMapping("/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 Store(src/store/index.js)仅维护 4 个模块,拒绝为每个组件建 namespace:

ModuleState 字段用途是否持久化
usertoken,permissions,userInfo登录态与权限localStorage
drawprogress,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-pluginfrontend-maven-plugin吓到——本项目已预配置好一键运行方案:

  1. 后端启动

    • 打开lottery-system-master根目录,在 IDEA 中右键pom.xmlMavenReload project
    • 确保src/main/resources/application.yml中数据库配置正确:
      spring: datasource: url: jdbc:mysql://localhost:3306/lucky_draw?useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password
    • 运行com.example.lottery.LotterySystemApplication主类,控制台输出Tomcat started on port(s): 8080即成功。
  2. 前端启动

    • 终端进入lottery-system-master/src/main/frontend目录(注意:不是项目根目录);
    • 执行npm install(首次)→npm run serve
    • 浏览器访问http://localhost:8080,自动跳转至/login

注意:前端vue.config.jsdevServer.proxy已配置代理:

devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } }

因此前端代码中所有 API 调用写api/v1/draw即可,无需拼接完整 URL。

4.2 生产环境 Docker 部署:分离前后端镜像,复用 MySQL 容器

项目未提供Dockerfile,但根据pom.xmlpackage.json可快速构建。推荐前后端分离部署,而非单体镜像:

后端镜像构建(Dockerfile.backend):
FROM openjdk:8-jdk-slim VOLUME /tmp ARG JAR_FILE=target/lottery-system-0.0.1-SNAPSHOT.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]

构建命令:

cd lottery-system-master mvn clean package -DskipTests docker build -f Dockerfile.backend -t lottery-backend .
前端镜像构建(Dockerfile.frontend):
FROM 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?useSSL=false&serverTimezone=Asia/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 user='root';若为localhost,需执行UPDATE mysql.user SET host='%' WHERE user='root'; FLUSH PRIVILEGES;
  • application.ymlurllocalhost在 Docker 内不可达,必须改为db(服务名);
  • 防火墙是否开放 3306 端口(Linux:sudo ufw allow 3306)。

5. 进阶技巧:如何将抽签系统嵌入现有 OA,并支持微信扫码查中奖

5.1 与 Spring Security 整合:复用企业统一认证体系

本项目默认使用UsernamePasswordAuthenticationFilter,但生产环境通常对接 LDAP 或 OAuth2。只需修改SecurityConfig.java

@Configuration @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_uri=http://your-app.com/login/callback),后端需实现/login/callback接收授权码并换取 token。

5.2 微信扫码查中奖:生成带参数二维码与免登录接口

为支持年会现场扫码查结果,需提供免登录的公开接口。在DrawRecordController.java中新增:

@GetMapping("/public/record/{batchId}") public ResponseEntity<List<PublicRecordDTO>> getPublicRecords(@PathVariable String batchId) { List<DrawRecord> records = drawRecordRepository.findByBatchId(batchId); List<PublicRecordDTO> 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.js):

import 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 抽签结果导出 Excel:Apache POI 与流式响应实践

DrawRecordController.exportRecords()方法实现零内存导出(避免大文件 OOM):

@GetMapping("/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; filename=draw_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]); } List<DrawRecord> 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,浏览器自动触发下载。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询