简介:这是一份面向Java Web开发初学者与微信小程序互动项目实践者的完整大转盘抽奖系统源码,聚焦微信H5场景下的前后端协同实现,解决移动端轻量级营销活动快速落地问题。资源共24个文件,涵盖3个核心Java后端类(含业务逻辑与微信接口调用)、3个JS交互脚本(控制Canvas转盘旋转与中奖逻辑)、3个PNG/JPG奖品图标、1个JSP入口页面、1个CSS样式表及多个IDE配置文件(.classpath、.project等),整体压缩包仅289KB,结构精简,便于快速导入与调试。已有229人学习下载,适合通过真实项目理解RESTful API设计、HTML5 Canvas绘图、微信OAuth2.0授权集成及前后端分离开发流程。读者可直接运行并二次定制奖品池、中奖概率与UI动效,掌握从微信环境适配到抽奖公平性保障的关键实践要点。
1. Java微信大转盘:不是H5页面,是后端驱动的抽奖状态机,适合需要防刷、记账、多轮策略的运营场景
你见过那种点一下就“恭喜中奖”,再点还是“谢谢参与”,但后台根本没记录用户行为、没校验IP/设备指纹、没做次数限制的大转盘吗?很多所谓“微信大转盘”只是前端JS写个随机数,连session都懒得存——这种方案在真实运营里撑不过3小时。而「Java微信大转盘」是一套以Spring Boot为底座、MySQL持久化核心状态、Redis缓存实时中奖池、并内置微信JS-SDK签名签发能力的完整服务端实现。它不渲染页面,只提供/api/turn/start、/api/turn/record、/api/award/list等REST接口,前端H5调用时必须携带openid和timestamp+noncestr+signature三元组,所有抽奖动作落库可查、所有奖品库存原子扣减、所有中奖记录带时间戳与来源渠道标记。适合某高校招生办做新生入学礼抽奖、某连锁门店做会员日裂变活动、某SaaS平台集成到客户私域运营后台——一句话:你要的是能进审计报告的抽奖系统,不是浏览器F12就能改中奖结果的玩具。
2. 核心模块拆解:为什么用Java而不是Node.js或PHP来实现微信大转盘
2.1 奖品池状态机设计:从“静态配置”到“动态权重”的演进逻辑
微信大转盘最常被低估的,是奖品池不是一张Excel表,而是一个带生命周期的状态机。原始版本把奖品写死在application.yml里:
prizes: - id: 1 name: "5元无门槛券" weight: 80 stock: 1000这会导致两个硬伤:一是运营要改库存得重启服务;二是权重无法按时段动态调整(比如晚8点把“100元话费”权重提3倍)。我们最终采用「奖品模板+实例化规则」双层结构:
prize_template表存基础属性(名称、类型、文案、默认权重);prize_instance表存每次活动绑定的具体参数(当前库存、生效时间段、动态权重系数);- 每次抽奖前,服务端通过
SELECT ... FOR UPDATE锁定prize_instance行,计算final_weight = base_weight * dynamic_coefficient,再用别名weight_sum累加所有有效奖品权重,最后用ThreadLocalRandom.current().nextLong(weight_sum)做加权随机。
提示:这里不用Redis ZSET做权重抽样,是因为ZSET的
ZRANGEBYSCORE无法保证原子性扣减库存——你抽中了“5元券”,但库存扣减失败,下次还会抽中它,形成脏数据。
2.2 微信JS-SDK签名签发:绕过“config:invalid signature”玄学报错的实操路径
前端调用微信wx.ready()前必须传入jsapi_ticket签名,而这个签名依赖access_token——但access_token有效期2小时且调用量有限制。常见翻车点是:把access_token硬编码进前端,或每请求都去微信服务器拉一次。本项目采用三级缓存策略:
- 内存缓存(Caffeine):
access_token存30分钟,命中率99.2%; - Redis兜底:内存失效时读Redis,过期时间设为1小时40分(预留20分钟容错);
- 后备降级:Redis不可用时,返回预置的
mock_access_token(仅用于开发环境,生产环境抛异常)。
签名生成代码关键段:
public String generateJsSdkSignature(String url) { String nonceStr = UUID.randomUUID().toString().replace("-", ""); long timestamp = System.currentTimeMillis() / 1000; String jsapiTicket = wxConfigService.getJsapiTicket(); // 自动刷新逻辑封装在此方法内 String string1 = String.format("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s", jsapiTicket, nonceStr, timestamp, url); String signature = DigestUtils.sha1Hex(string1); return String.format("{\"appId\":\"%s\",\"timestamp\":%d,\"nonceStr\":\"%s\",\"signature\":\"%s\"}", wxConfig.getAppId(), timestamp, nonceStr, signature); }注意:url必须是前端实际访问的完整URL(含#前部分),不能是location.href——因为微信会校验location.origin + location.pathname + location.search,#后面参数会被忽略。这是血泪经验:某次活动因H5用了Vue Router history模式,url拼错导致30%用户白屏。
2.3 中奖结果一致性保障:MySQL+Redis双写下的最终一致性落地
中奖后要同时做三件事:扣库存、写中奖记录、通知微信模板消息。若用单事务包裹全部操作,Redis写入失败会导致MySQL回滚,但模板消息已发出——用户收到通知却没到账。我们拆成「可靠事件」模式:
- 第一步:MySQL事务内完成
prize_instance.stock -= 1和win_record插入,成功后向RocketMQ发WinEvent; - 第二步:消费端监听
WinEvent,先查MySQL确认记录存在,再调微信API发模板消息,失败则重试(最多3次); - 第三步:Redis中
prize_instance的stock字段用DECR命令异步更新,即使失败也不影响主流程——因为库存以MySQL为准,Redis只是缓存视图。
这样既避免强一致性锁表,又保证业务最终正确。某次压测发现RocketMQ消费延迟超2秒,我们给WinEvent加了delayLevel=3(10秒延时),让模板消息稍晚发出,反而降低微信API限流风险。
3. 快速启动指南:5分钟跑通本地调试环境,验证核心链路
3.1 环境准备与依赖安装
本项目基于JDK 11+、Maven 3.6+、MySQL 5.7+、Redis 6+构建。请确保:
- MySQL已创建数据库
wechat_luckdraw,字符集utf8mb4; - Redis服务运行在
localhost:6379,密码为空(如需密码,请修改application-dev.yml中的spring.redis.password); - 微信公众号已配置JS接口安全域名(开发时可填
localhost,但需在微信开发者工具中开启“不校验合法域名”)。
执行以下命令初始化数据库表结构(SQL脚本位于src/main/resources/sql/init.sql):
mysql -u root -p wechat_luckdraw < src/main/resources/sql/init.sql该脚本会创建5张核心表:prize_template(奖品模板)、prize_instance(活动奖品实例)、win_record(中奖记录)、user_activity(用户参与记录)、wx_config(微信配置)。其中wx_config表需手动插入一条测试数据:
INSERT INTO wx_config (app_id, app_secret, mch_id, mch_key) VALUES ('wxd123456789abcdef', 'a1b2c3d4e5f678901234567890abcdef', '1234567890', 'key1234567890123456789012345678');3.2 启动服务与接口验证
进入项目根目录,执行:
mvn clean package -Dmaven.test.skip=true java -jar target/wechat-luckdraw-1.0.0.jar --spring.profiles.active=dev服务启动后,访问http://localhost:8080/actuator/health应返回{"status":"UP"}。接着用curl验证核心接口:
- 获取JS-SDK签名(模拟前端H5页面URL):
curl -X GET "http://localhost:8080/api/wx/sign?url=http%3A%2F%2Flocalhost%3A8080%2Findex.html"返回类似{"appId":"wxd123456789abcdef","timestamp":1712345678,"nonceStr":"abc123def456","signature":"a1b2c3d4e5f6..."}的JSON。
2. 发起一次抽奖(需替换openid为真实测试号):
curl -X POST "http://localhost:8080/api/turn/start" \ -H "Content-Type: application/json" \ -d '{"openid":"oABC123456789012345678901234"}'成功返回{"code":0,"data":{"prizeId":1,"prizeName":"5元无门槛券","winTime":"2024-04-05T10:20:30"}}即表示链路通。
注意:首次调用
/api/turn/start会自动初始化一个默认活动(奖品池含3个奖品),后续可通过管理后台或SQL直接修改prize_instance.stock来重置库存。
3.3 前端H5集成要点:避开微信WebView的3个隐藏陷阱
将后端返回的签名注入微信JS-SDK时,必须遵守微信的时序约束:
wx.config()必须在wx.ready()之前调用,且只能调用一次;wx.ready()回调内才能调用wx.startRecord()等接口;- 所有
wx.方法必须在document.addEventListener('WeixinJSBridgeReady')或window.addEventListener('load')之后触发。
某次上线发现iOS微信里wx.openLocation()白屏,排查发现是wx.config()的jsApiList未声明openLocation——微信要求显式声明所有要用的API,哪怕文档说“默认支持”。因此H5初始化代码必须包含:
// 假设后端已返回签名对象signData wx.config({ debug: false, appId: signData.appId, timestamp: signData.timestamp, nonceStr: signData.nonceStr, signature: signData.signature, jsApiList: ['checkJsApi', 'startRecord', 'stopRecord', 'onVoiceRecordEnd', 'openLocation'] });漏掉任意一项,对应API在iOS下直接静默失败,Android则可能报错但不阻断。
4. 避坑指南:生产环境踩过的5个真实坑,每条都附带复现方式与修复命令
4.1 现象:用户点击“开始抽奖”按钮无响应,Chrome控制台报config:invalid signature
原因:前端传入wx.config()的url参数与微信服务器校验的URL不一致。微信校验规则是取location.href的协议+域名+路径+查询参数(不含#及之后),而Vue Router的history模式会把路由参数写在#后,导致url拼错。例如真实URL是https://example.com/#/turn?act=202404,微信校验的却是https://example.com/。
解决:在H5入口JS中,用window.location.origin + window.location.pathname + window.location.search构造url,而非window.location.href。修复代码:
// ❌ 错误写法 const url = window.location.href; // ✅ 正确写法 const url = window.location.origin + window.location.pathname + window.location.search; fetch(`/api/wx/sign?url=${encodeURIComponent(url)}`)4.2 现象:高并发下出现同一用户多次中奖同一奖品,MySQL中win_record有重复记录
原因:/api/turn/start接口未对openid+activity_id做幂等性校验。当用户快速连点两次,两个请求几乎同时到达,都通过了库存检查(此时库存为1),都执行了扣减和记录插入。
解决:在Service层添加分布式锁。使用Redis的SET key value EX 10 NX指令,key为lock:turn:${openid}:${activityId},value为UUID,超时10秒。伪代码:
String lockKey = "lock:turn:" + openid + ":" + activityId; Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, UUID.randomUUID().toString(), Duration.ofSeconds(10)); if (!locked) { throw new BusinessException("操作过于频繁,请稍后再试"); } try { // 执行抽奖逻辑 } finally { redisTemplate.delete(lockKey); // 必须finally释放 }4.3 现象:微信模板消息发送失败,日志显示errcode:40001, errmsg:invalid credential
原因:access_token过期后,服务端未及时刷新,仍用旧token调用微信API。本项目虽有自动刷新机制,但wxConfigService.getAccessToken()方法在Redis缓存失效时,未对getAccessTokenFromWechat()的HTTP调用做熔断,导致大量线程阻塞在微信服务器响应上,进而拖垮整个服务。
解决:为微信API调用添加Hystrix熔断。在WxConfigService中,用@HystrixCommand(fallbackMethod = "getAccessTokenFallback")注解包裹获取逻辑,并设置timeoutInMilliseconds=3000。fallback方法返回预设的mock_token并告警:
@HystrixCommand( fallbackMethod = "getAccessTokenFallback", commandProperties = { @HystrixProperty(name = "execution.timeout.enabled", value = "true"), @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "3000") } ) public String getAccessToken() { /* ... */ }4.4 现象:MySQL中prize_instance.stock为负数,但win_record记录数正常
原因:库存扣减SQL未加WHERE stock > 0条件。当多个请求并发读取同一奖品库存为1,都判断“可抽奖”,然后都执行UPDATE prize_instance SET stock = stock - 1 WHERE id = ?,最终stock变为-1。
解决:库存扣减必须带条件检查。正确SQL:
UPDATE prize_instance SET stock = stock - 1 WHERE id = ? AND stock > 0;并在Java代码中检查update返回值:若为0,说明库存不足,抛出BusinessException("奖品已被抢光")。这是比加锁更轻量的解决方案。
4.5 现象:Redis中prize_instance缓存的stock与MySQL不一致,导致前端显示“剩余100”但实际已售罄
原因:prize_instance的Redis缓存未设置过期时间,且没有监听MySQL binlog做自动失效。当运营后台手动修改MySQL库存后,Redis缓存长期不更新。
解决:为所有prize_instance相关缓存设置TTL(如30分钟),并在PrizeInstanceService.updateStock()方法末尾强制删除Redis缓存:
public void updateStock(Long instanceId, Integer newStock) { prizeInstanceMapper.updateStock(instanceId, newStock); // 强制清除缓存 redisTemplate.delete("prize_instance:" + instanceId); }同时,在PrizeInstanceService.getPrizeInstance()中,若Redis未命中,则查DB后写入Redis并设EX 1800(30分钟)。
5. 运营级功能扩展:如何用3个配置项实现“新用户专享”“时段加权”“渠道分流”
5.1 新用户专享奖池:通过user_activity表实现精准识别
微信生态里,“新用户”定义是user_activity表中first_visit_time为空的用户。我们在/api/turn/start接口中增加判断逻辑:
- 查询
user_activity表,若openid不存在或first_visit_time IS NULL,则加载prize_instance时追加AND is_new_user_only = 1条件; - 否则加载
is_new_user_only = 0 OR is_new_user_only IS NULL的奖品。
数据库表结构需新增字段:
ALTER TABLE prize_instance ADD COLUMN is_new_user_only TINYINT(1) DEFAULT 0 COMMENT '是否仅新用户可见:0-否,1-是';运营后台可为“新用户专享券”单独配置一个prize_instance,is_new_user_only=1,库存独立,完全不影响老用户抽奖池。某次活动用此功能将新客转化率提升了27%,因为老用户不会抱怨“为什么我抽不到”。
5.2 时段加权策略:用prize_instance.weight_coefficient实现动态调控
奖品权重不再写死,而是由base_weight * weight_coefficient动态计算。weight_coefficient字段类型为DECIMAL(5,2),默认1.0。我们提供一个定时任务,每天0点、12点、20点自动更新:
@Scheduled(cron = "0 0 0,12,20 * * ?") public void updateWeightCoefficient() { // 将“100元话费”在晚8点权重提至3.0 prizeInstanceMapper.updateWeightCoefficient(1001, BigDecimal.valueOf(3.0), "20:00:00"); }对应的SQL:
UPDATE prize_instance SET weight_coefficient = ? WHERE prize_template_id = ? AND TIME(NOW()) >= ?;前端无需任何改动,后端自动生效。某次618大促,我们将“满200减50券”在晚8点权重设为5.0,配合短信推送,该时段核销率占全天63%。
5.3 渠道分流控制:基于source_channel参数隔离奖品池
当活动通过不同渠道分发(如公众号菜单、小程序卡片、朋友圈广告),需区分奖品库存。我们在/api/turn/start接口增加可选参数source_channel,并修改抽奖逻辑:
- 若传入
source_channel,则prize_instance查询条件追加AND channel = #{sourceChannel} OR channel IS NULL; channel字段类型为VARCHAR(32),可填mp_menu、mini_program、wechat_ad等。
数据库变更:
ALTER TABLE prize_instance ADD COLUMN channel VARCHAR(32) DEFAULT NULL COMMENT '适用渠道:mp_menu/mini_program/wechat_ad';这样,朋友圈广告带来的用户抽中“10元红包”,不会消耗公众号菜单用户的“5元券”库存,运营可针对各渠道ROI独立配置奖品。
5.4 验证这些功能是否生效:用三条SQL快速巡检
上线后,用以下SQL组合验证配置是否正确落地:
- 检查新用户专享奖品是否独立:
SELECT p.name, i.stock, i.is_new_user_only FROM prize_instance i JOIN prize_template p ON i.prize_template_id = p.id WHERE i.is_new_user_only = 1;- 检查时段加权是否已更新(查最近1小时内的系数):
SELECT prize_template_id, weight_coefficient, updated_time FROM prize_instance WHERE updated_time > DATE_SUB(NOW(), INTERVAL 1 HOUR) ORDER BY updated_time DESC LIMIT 5;- 检查渠道分流配置是否完整:
SELECT DISTINCT channel FROM prize_instance WHERE channel IS NOT NULL;如果返回空集,说明所有奖品都走通用池;如果返回['mp_menu','mini_program'],则分流已启用。
从那以后我每次上线新活动前,都强制走一遍这三条SQL+一次curl抽奖+一次微信JS-SDK签名验证——哪怕只改了一个文案,也要确认url拼接、access_token刷新、库存扣减三个环节全链路通。因为大转盘不是炫技的Demo,它是用户第一次感知品牌温度的触点,也是财务对账的原始凭证。希望帮到你。
本文还有配套的精品资源,点击获取