简介:这是一套面向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)和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_id、participant_id、operator(操作员账号)、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 问题。本项目采用数据库行锁 + 应用层乐观锁双保险:
- 数据库层:在
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); 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(),内置三重保障:
- 请求防抖:连续点击「开始抽签」按钮,第二次点击会被忽略(
lodash.debounce); - Loading 状态绑定:使用
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.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:
| Module | State 字段 | 用途 | 是否持久化 |
|---|---|---|---|
user | token,permissions,userInfo | 登录态与权限 | localStorage |
draw | progress,winners,batchId | 抽签过程状态 | 内存(抽完即清) |
prize | list,currentPrize | 奖品列表与选中项 | localStorage(缓存1小时) |
record | history,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?useSSL=false&serverTimezone=Asia/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.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.yml中url的localhost在 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,浏览器自动触发下载。
本文还有配套的精品资源,点击获取