简介:面向微信平台互动抽奖场景的Java大转盘项目,适合具备Java Web与HTML5基础、希望了解微信内嵌H5活动页实现方式的初中级开发者。项目以Java处理后端业务与微信接口交互,前端借助HTML5、CSS3和JavaScript完成转盘绘制、旋转动画与页面交互,并涉及RESTful接口、用户授权、抽奖记录存储及基础安全与响应式适配等知识点,可用于学习前后端分离的活动页搭建流程。压缩包共24个文件、约289KB,包含java、jsp、class等后端与页面代码,js、css等前端脚本样式,png、jpg图片素材,以及xml、prefs、project等工程配置和元数据文件,目录保留较完整的项目结构,便于对照阅读。已有231人学习下载,适合作为微信抽奖类小项目的结构参考与实现思路样本,帮助梳理转盘动画、接口调用和工程组织方式。
1. 抽奖不是转一下那么简单:一个 Java 大转盘项目能解决什么
很多人第一次接到“做个大转盘”的需求,脑子里浮现的是前端 CSS 旋转动画。真到写代码才发现,动画是最不值钱的部分。真正难的是:奖品库存怎么扣、用户一天能抽几次、中奖概率按权重怎么算、并发下会不会超发、抽完了怎么补偿。我之前在一个活动系统里就翻过车——两个人同时抽中最后一张券,数据库里库存变成 -1,被运营追着问了一下午。
这个 Java 微信大转盘项目,核心解决的就是这套后端抽奖链路:奖品配置、权重概率、库存扣减、抽奖次数限制、记录落库,配合微信侧的入口做活动页。适合正在做营销活动、积分商城、小程序互动页的后端开发,也适合想搞清楚“抽奖系统到底怎么写才不出事”的同学。前端转盘只是它的壳,里面那套概率与库存的控制才是主体。
2. 技术选型与目录结构:先看清这套代码的骨架
2.1 为什么是 Spring Boot + MyBatis 这套组合
抽奖业务的特点是读多写少、瞬时并发高、逻辑集中在少数几个接口上。这种场景用 Spring Boot 起步最省事:内嵌容器、starter 依赖、配置外置,一个 jar 扔到服务器上就能跑。持久层选 MyBatis 而不是 JPA,主要是奖品配置表、抽奖记录表这类结构比较固定但查询条件多变的表,手写 SQL 更好控,尤其是扣库存那句update ... where stock > 0,用 ORM 生成反而绕。
缓存层大概率会引入 Redis。奖品池、用户当日剩余抽奖次数这类数据放数据库里扛不住瞬时流量,常见做法是活动开始前把奖品的 id、权重、剩余库存预热到 Redis,抽奖时先走缓存判断,再异步或同步落库。这不是项目正文写明的,而是这个业务场景下合格开发基本都会做的事,我在几个类似活动里也都是这么搭的。
事务边界也值得说一句。抽奖涉及“扣次数 + 扣库存 + 写记录”三步,这三步必须在同一个事务里,否则会出现次数扣了奖品没发,或者奖品发了次数没扣。用@Transactional包住服务层方法是最低要求,注意别把远程调用(比如发券接口)放事务里,会拖长事务持有时间。
2.2 目录结构与各层职责
一个典型的 Java 大转盘后端,目录大致长这样:
src/main/java/com/example/lottery ├── controller 接口层,暴露抽奖、奖品列表、记录查询 ├── service 业务层,抽奖核心逻辑、库存扣减、次数校验 │ └── impl ├── mapper MyBatis 映射接口 ├── entity 数据库实体:Prize、LotteryRecord、UserTimes ├── dto 请求/响应对象 ├── config 数据源、Redis、微信相关配置 ├── common 统一返回、异常、常量、工具类 └── task 定时任务:库存回补、记录清理 resources ├── mapper XML SQL 文件 ├── application.yml 主配置 └── sql 建表脚本分层的意义在于改的时候知道往哪下手。想调中奖概率,去 service;想加一个奖品字段,entity、mapper XML、dto 三处同步改;想换缓存方案,只动 config 和 service 里对缓存的调用。我见过把抽奖逻辑全塞 controller 里的写法,后来加个“保底必中”需求,改动面直接失控。
2.3 建表脚本与核心字段
抽奖系统至少三张表,理解字段比理解代码更重要:
-- 奖品配置表 CREATE TABLE t_prize ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '奖品名称', type TINYINT NOT NULL COMMENT '1实物 2优惠券 3积分 4谢谢参与', weight INT NOT NULL COMMENT '权重,概率=weight/总权重', stock INT NOT NULL COMMENT '剩余库存', total_stock INT NOT NULL COMMENT '总库存', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 用户抽奖记录表 CREATE TABLE t_lottery_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL COMMENT '微信 openid', prize_id BIGINT NOT NULL, prize_name VARCHAR(64) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id, create_time) ); -- 用户抽奖次数表 CREATE TABLE t_user_times ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, day DATE NOT NULL, used INT DEFAULT 0 COMMENT '已用次数', total INT DEFAULT 3 COMMENT '每日总次数', UNIQUE KEY uk_user_day (user_id, day) );字段设计上有两个细节容易忽略。weight存权重而不是存百分比,是因为奖品数量变动时不用重算所有概率,加总即可;stock和total_stock分开存,方便活动结束后统计发放率。t_user_times用(user_id, day)做唯一键而不是每次 count 记录表,是为了避免高频 count 查询把记录表压垮。
3. 抽奖核心链路:从一次请求到奖品落库
3.1 权重算法的实现与坑
抽奖的本质是“按权重从候选集合里取一个”。最直白的写法是累加权重法:
public Prize drawByWeight(List<Prize> prizes) { // 只统计库存充足且在架上的奖品 List<Prize> candidates = prizes.stream() .filter(p -> p.getStatus() == 1 && p.getStock() > 0) .collect(Collectors.toList()); int totalWeight = candidates.stream().mapToInt(Prize::getWeight).sum(); if (totalWeight <= 0) { return null; // 全部无货,返回兜底 } // 随机落点,落在哪个权重区间就中哪个奖 int hit = ThreadLocalRandom.current().nextInt(totalWeight); int cursor = 0; for (Prize p : candidates) { cursor += p.getWeight(); if (hit < cursor) { return p; } } return candidates.get(candidates.size() - 1); }逻辑说明:先把库存为 0 或下架的奖品剔除,避免抽到发不出去的奖;把有效奖品的权重加总,在其中随机取一个整数落点;再遍历累加,落点落在哪个区间就返回哪个奖品。这里用ThreadLocalRandom而不是Random,是因为前者在多线程下没有竞争,抽奖接口并发时性能差别明显。
参数上要留意weight的取值。如果某个奖品权重设成 0,它永远不会被抽中,但仍然会出现在候选列表里浪费一次遍历;更隐蔽的问题是,如果“谢谢参与”的权重没配,所有有效奖品权重加总后会偏小,整体中奖率被放大。我的习惯是活动配置时强制要求所有奖品的权重总和为固定值(比如 10000),这样运营调一个奖品权重,其余按比例自动变化,不会出现“概率加起来不是 100%”的玄学问题。
3.2 库存扣减:用条件更新对冲并发
概率算对了奖品,接下来最难的是扣库存。先讲错误示范:
// 反例:先查再改,并发下必然超发 Prize prize = mapper.selectById(id); if (prize.getStock() > 0) { mapper.updateStock(id, prize.getStock() - 1); }两个线程同时查到stock = 1,都判断通过,都执行更新,库存变成 -1。正确做法是把判断塞进 SQL 的 where 条件里,靠数据库的行锁保证原子性:
<!-- PrizeMapper.xml --> <update id="reduceStock"> UPDATE t_prize SET stock = stock - 1 WHERE id = #{id} AND stock > 0 </update>int affected = prizeMapper.reduceStock(prize.getId()); if (affected == 0) { // 扣减失败说明库存刚被抢完,回滚这一轮,重新抽 throw new BizException("奖品已抢完,请重试"); }affected返回受影响行数,等于 1 才算扣成功,等于 0 表示条件不满足(库存已空)。这个方式不依赖任何锁,靠数据库自身的行级锁兜底,在单库场景下足够稳。要注意的是,这段逻辑必须和写抽奖记录、扣用户次数放在同一个@Transactional方法里,否则扣了库存记录没写,库存就白扣了。
如果是多库分片或者库存必须放 Redis 的场景,常见做法是 Redis 里用DECR扣减,扣到负数再回补,配合 Lua 脚本保证“判断 + 扣减”原子执行。项目如果没用到 Redis,用上面这套条件更新就够了,别为了炫技引入不必要的复杂度。
3.3 抽奖次数限制与幂等
次数限制的两个核心问题:一天能抽几次,以及同一次请求重复提交算不算。
@Transactional(rollbackFor = Exception.class) public LotteryResult draw(String openid) { // 1. 抢占当日次数(条件更新,等价于加锁) int affected = userTimesMapper.consumeTimes(openid, LocalDate.now()); if (affected == 0) { throw new BizException("今日抽奖次数已用完"); } // 2. 按权重选出奖品 List<Prize> prizes = prizeMapper.listOnline(); Prize prize = drawByWeight(prizes); // 3. 扣库存,失败则抛异常回滚次数 if (prize != null) { int stockAffected = prizeMapper.reduceStock(prize.getId()); if (stockAffected == 0) { throw new BizException("奖品已抢完,请重试"); } // 4. 落记录 recordMapper.insert(buildRecord(openid, prize)); } return buildResult(prize); }对应的次数扣减 SQL:
<update id="consumeTimes"> UPDATE t_user_times SET used = used + 1 WHERE user_id = #{openid} AND day = #{day} AND used < total </update>这里used < total是关键,和扣库存同理,把“还有没有次数”的判断交给数据库。整个方法包在事务里,任何一步抛异常都会把次数恢复回去,用户不会白白损失一次机会。
幂等这块,光靠次数限制不够。用户在弱网下点两下,可能发出两个请求,第一个还没返回第二个就到了,次数被扣两次。常见做法是前端在按钮上做防抖锁,后端再加一层请求 token:进页面时发一个一次性 token 存 Redis,抽奖时带上,服务端DEL成功才放行。项目里如果前端没有这套机制,至少应该在接口层按 openid 做短时间的重复请求拦截,最简单的实现是 RedisSETNX openid:draw 1 EX 3,三秒内同一用户重复请求直接拒绝。
3.4 兜底奖与保底策略
抽奖系统有个必须提前想清楚的问题:候选奖品全部库存为 0 时返回什么。drawByWeight里我返回了 null,上层如果不处理,用户看到的就是“抽奖失败”,体验很差。常规做法是配一个type = 4的“谢谢参与”,权重设得足够大,且库存设成一个不会耗尽的大数(比如 999999),保证任何时候都有兜底结果。
保底策略是另一层需求:连抽 N 次不中就给个安慰奖。实现方式一般是在抽奖记录表上做 count,超过阈值时强制返回指定奖品。这个逻辑别写在权重算法里,抽完结果后再判一次,改写最终奖品,因为保底是业务规则,不是概率规则,混在一起以后调概率会互相干扰。
4. 跑起来与联调:本地起服务到微信侧对接
4.1 环境准备与启动步骤
这套项目本地跑起来不算复杂,基本路径是:装 JDK 8 或以上、Maven 3.6+、MySQL 5.7/8.0,可选 Redis。步骤按顺序走:
# 1. 建库建表 mysql -u root -p -e "CREATE DATABASE lottery DEFAULT CHARSET utf8mb4;" mysql -u root -p lottery < src/main/resources/sql/init.sql # 2. 改配置,把数据源指到本地 # application.yml 里改 spring.datasource.url / username / password # 3. 编译打包 mvn clean package -DskipTests # 4. 启动 java -jar target/lottery-0.0.1-SNAPSHOT.jar # 或者开发期直接跑 mvn spring-boot:run启动后访问http://localhost:8080/prize/list能看到奖品列表 JSON,说明链路通了。如果这一步就报错,先看控制台日志里的 SQL 异常,八成是建表脚本没执行或者字段名对不上。Redis 是可选项,配置里如果写了 Redis 但本地没起服务,启动时连接失败会导致整个应用起不来,这种情况要么起个 Redis,要么把相关配置注释掉,别硬扛。
4.2 微信侧对接要注意的几件事
项目带微信入口,意味着要考虑授权和 openid 获取。常规链路是:用户从公众号菜单或小程序进入活动页 → 页面跳转微信授权 → 拿 code 换 openid → 带着 openid 调抽奖接口。后端一般有两个接口配合:一个用 code 换 openid,一个把 openid 和 session 绑起来返回给前端。
本地联调时这块最容易卡住,因为微信授权回调域名必须是备案过的线上域名,localhost 不行。常规做法是也用一个内网穿透工具把本地端口映射出去,配到公众号的网页授权域名里;或者干脆在开发环境做个开关,跳过微信授权,直接用固定的测试 openid 调抽奖接口,把业务逻辑先跑通,微信联调放到测试环境再做。这个开关一定要做得干净,别把绕过授权的代码带到生产环境去。
access_token 的缓存也值得提一句。微信接口的 access_token 有调用频率限制,且全局唯一,必须缓存起来复用。放在 Redis 里设一个比官方过期时间短一点的 TTL,比如官方 7200 秒,缓存设 7000 秒,避免临界点失效。
4.3 接口自测清单
联调前建议把这几条自己先走一遍,能省掉大量和前端来回扯皮的时间:
| 场景 | 预期结果 | 检查点 |
|---|---|---|
| 正常抽奖 | 返回奖品名和记录 id | 库存减 1,记录表新增一行 |
| 次数用尽 | 返回“次数已用完” | 库存不变,无新记录 |
| 奖品库存为 0 | 抽到其他奖或谢谢参与 | 不出现库存为负 |
| 并发 50 次请求 | 成功次数不超过库存 | stock最小为 0 |
| 重复提交 | 只产生一条记录 | 次数只扣一次 |
并发那条可以用ab或 JMeter 打,重点看最终库存和记录数是否对得上。我一般会专门跑一轮库存 10、并发 50 的测试,跑完查select stock from t_prize where id = ?,只要出现负数就说明扣减逻辑有问题。
5. 避坑排查:这几处翻车点我基本每次都能遇到
5.1 库存超发,记录和库存对不上
现象:活动结束后盘点,奖品发放数量超过总库存。原因通常是扣库存用了“先查再改”的写法,或者扣库存和写记录不在同一个事务里。解决:扣减一律用update ... set stock = stock - 1 where stock > 0,判断affected行数;整条抽奖链路放进同一个事务,且事务方法上标rollbackFor = Exception.class,否则受检异常不会回滚。
5.2 概率偏差,某个奖品几乎不中
现象:运营反馈某个权重不低的奖品实际中奖率极低。原因一般是候选列表里混进了库存为 0 的奖品,或者权重字段在数据库里是字符串类型,比较时按字典序走了。解决:抽奖前先过滤status = 1 and stock > 0,并在配置页面加校验,所有有效奖品权重总和必须是约定值,改完权重打印一次实际概率分布核对。
5.3 次数被重复扣,用户投诉少了一次机会
现象:用户说点了一次按钮,次数却少了两次。原因在前面提过,弱网下重复请求,或者前端按钮没锁。解决:后端接口做幂等,用 RedisSETNX拦截短时间内的重复请求;前端按钮点击后置灰,拿到响应再恢复;次数扣减用条件更新,避免used > total的脏数据。
5.4 事务里调用远程接口,库锁等待超时
现象:活动高峰期出现大量Lock wait timeout exceeded。原因是抽奖方法里调了发券、发消息这类远程接口,事务迟迟不提交,行锁一直持有。解决:把远程调用移出事务边界,先在事务内完成扣库存和落记录,事务提交后再发券;如果发券失败,用补偿任务或消息队列重试,不要为了强一致把事务拉长。
5.5 时区导致“今天”算错
现象:凌晨零点到八点之间抽奖,提示次数已用完或者次数重置了。原因是服务器时区是 UTC,LocalDate.now()取到的是前一天或后一天。解决:统一在配置文件里指定spring.jackson.time-zone和 JVM 的-Duser.timezone=Asia/Shanghai,数据库连接串上也加serverTimezone=Asia/Shanghai,三处对齐。
6. 二次开发:把固定活动改成可配置的抽奖平台
拿到这套代码,最直接的改造方向是把“写死的活动”变成“多活动可配置”。原项目的奖品和次数大概率是全局一份,实际业务里往往要支持同时跑多个活动,每个活动独立奖池、独立次数规则。我的改法是在t_prize和t_user_times上都加一个activity_id,抽奖接口入参带上活动标识,缓存 key 也按活动维度隔开,这样一套服务能支撑多个入口。
第二个方向是概率策略外置。现在权重写在数据库字段里,改一次要动数据。更好的做法是抽奖时从配置中心或缓存里读一份概率表,权重值支持热更新,运营调概率不需要重启也不需要改库。实现上把drawByWeight的入参从List<Prize>换成List<PrizeWeight>,权重来源抽成独立方法,方便替换。
第三个方向是抽奖记录和风控打通。真实活动里刷奖是不小的损失,常见做法是按 openid 和设备指纹统计抽奖频次,短时间内高频请求直接进黑名单,抽奖接口前置一层风控校验。这块不要塞进抽奖主逻辑,做成拦截器或独立 service,命中风控时返回统一文案,避免暴露判定规则。
验证改造是否成功,我习惯先跑那条“库存 10、并发 50”的压力测试,再走一遍次数边界:把用户次数设成 1,连点三次,确认只产生一条记录。改完这两条都过了,基本就稳了。
说个我自己的习惯。每次接抽奖类需求,我会先在本子上把“奖品、库存、次数、概率、兜底”五个词写下来,然后逼着自己回答:并发下这五个词分别在哪一步可能出错。这五个问题答不上来,代码写得再顺我也不敢上线。从那以后我每次做这类活动都强制走一遍这个自检,省下的返工时间远比写检查的时间多。希望帮到你。
本文还有配套的精品资源,点击获取