☰
Java微信大转盘高并发实战:JS-SDK签名+Redis原子扣奖+防刷风控
2026/10/13 4:00:07 网站建设 项目流程

简介:这是一份面向Java Web开发初学者与微信互动项目实践者的完整大转盘抽奖系统源码,聚焦微信H5场景下的前后端协同实现。资源包含24个文件,涵盖3个核心Java后端类(含业务逻辑与微信接口交互)、3个JS脚本(控制Canvas转盘动画与AJAX请求)、1个JSP入口页面、1个CSS样式文件、3个PNG/JPG奖品图标及若干IDE配置文件(.classpath、.project等),整体压缩包仅289KB,轻量易部署。已有229人学习下载,适合快速理解微信OAuth授权、RESTful API对接、HTML5 Canvas动态绘图与CSS3旋转动画的整合逻辑。读者可直接导入MyEclipse运行,完整复现抽奖流程,掌握抽奖公平性校验、用户数据存储(预览中含WEB-INF与META-INF结构)、响应式适配及HTTPS安全通信等关键实践要点。

1. Java微信大转盘:不是写个抽奖页面就叫“大转盘”,它得扛住3000人同时点、微信JS-SDK签名不崩、中奖逻辑可审计且防刷

“Java微信大转盘”这六个字,常被当成一个前端动画+后端随机数的速成项目——但真在某高校迎新系统、某连锁商超会员活动里跑起来,你很快会发现:页面转得再炫,只要用户一刷新就中奖、同一IP十分钟连中三台iPhone、JS-SDK签名过期报错“config:invalid signature”、后台日志查不到谁在哪秒中了什么奖,整个活动就等于没做。这不是UI问题,是典型的高并发轻计算+微信生态强耦合+业务规则硬约束三重压力下的工程落地场景。它适合两类人:一是刚接手营销类后台的Java后端工程师,需要快速交付一个可上线、可监控、可对账的抽奖模块;二是正在做微信公众号/小程序整合的全栈开发者,必须把Java服务端和微信JS-SDK、微信支付回调、用户OpenID绑定这些链路串通。本文不讲CSS旋转动画怎么写,只聚焦Java侧如何构建一个真实生产环境可用的大转盘服务骨架:从微信签名生成到中奖策略隔离,从并发扣奖原子性到防刷日志埋点,每一步都带可验证的代码、参数依据和血泪踩坑记录。


2. 搭建微信JS-SDK签名服务:用Java生成config接口所需的nonceStr、timestamp、signature

微信大转盘前端必须调用wx.config()初始化JS-SDK,而signature字段不能硬编码,必须由后端动态生成。这个过程看似简单,实则暗藏三个关键陷阱:签名算法必须严格按微信文档拼接字符串、jsapi_ticket必须缓存且自动刷新、timestamp必须是秒级时间戳(非毫秒)。常见错误是直接拿access_token去签名,或把timestamp写成System.currentTimeMillis()。

2.1 获取并缓存jsapi_ticket:避免每请求都调微信接口

微信要求signature基于jsapi_ticket生成,而jsapi_ticket有效期2小时,需主动缓存。我们用ConcurrentHashMap加双重检查锁实现线程安全缓存:

@Component public class JsApiTicketManager { private static final String JSAPI_TICKET_URL = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=%s&type=jsapi"; private volatile String jsapiTicket; private volatile long expireTime; @Autowired private WeChatAccessTokenManager accessTokenManager; public String getJsApiTicket() throws IOException { if (System.currentTimeMillis() > expireTime) { synchronized (this) { if (System.currentTimeMillis() > expireTime) { String accessToken = accessTokenManager.getAccessToken(); String url = String.format(JSAPI_TICKET_URL, accessToken); JSONObject response = HttpClientUtil.doGet(url); if (response.getInt("errcode") == 0) { this.jsapiTicket = response.getString("ticket"); this.expireTime = System.currentTimeMillis() + (response.getLong("expires_in") - 200) * 1000; // 提前200秒刷新 } else { throw new RuntimeException("获取jsapi_ticket失败: " + response.getString("errmsg")); } } } } return jsapiTicket; } }

注意:expireTime减去200秒是关键——微信返回的expires_in是7200秒,但网络延迟和时钟漂移可能导致刚过期就失效。提前200秒刷新能避免大量请求撞上过期窗口。

2.2 生成config签名:字符串拼接顺序和URL编码必须零误差

签名生成必须严格按微信文档:jsapi_ticket=some_ticket&noncestr=some_nonce&timestamp=some_timestamp&url=some_url,且url必须是当前页面完整URL(含hash前的部分),不能urlencode。很多翻车源于url用了URLEncoder.encode(),或noncestr用了UUID带短横线(微信要求纯字母数字)。

@Service public class JsSdkSignService { @Autowired private JsApiTicketManager ticketManager; public JsSdkConfig generateConfig(String currentPageUrl) throws Exception { String nonceStr = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 16); long timestamp = System.currentTimeMillis() / 1000; // 秒级时间戳! String jsapiTicket = ticketManager.getJsApiTicket(); // 拼接原始字符串:key=value&key=value... 且必须按字典序排列 TreeMap<String, String> params = new TreeMap<>(); params.put("jsapi_ticket", jsapiTicket); params.put("noncestr", nonceStr); params.put("timestamp", String.valueOf(timestamp)); params.put("url", currentPageUrl); // 注意:currentPageUrl 必须是前端发起wx.config的页面URL,不含#及之后内容 StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> entry : params.entrySet()) { sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&"); } String plain = sb.substring(0, sb.length() - 1); // 去掉末尾& String signature = DigestUtils.sha1Hex(plain); return new JsSdkConfig(nonceStr, timestamp, signature); } }

逻辑说明:TreeMap保证键按字典序排序,这是微信签名强制要求;DigestUtils.sha1Hex来自Apache Commons Codec,比原生MessageDigest更简洁;currentPageUrl必须由前端传入(如https://xxx.com/lottery/index),后端不可自行构造,否则签名无效。

2.3 Controller暴露签名接口:供前端AJAX调用

前端在页面加载后,先请求此接口获取签名参数,再调wx.config():

@RestController @RequestMapping("/api/wx") public class WxJsSdkController { @Autowired private JsSdkSignService signService; @GetMapping("/jsapi-config") public Result<JsSdkConfig> getJsApiConfig(@RequestParam String url) { try { JsSdkConfig config = signService.generateConfig(url); return Result.success(config); } catch (Exception e) { log.error("生成JS-SDK签名失败", e); return Result.fail("签名生成失败"); } } }

参数说明:@RequestParam String url必须是前端当前页面的完整URL(协议+域名+路径),例如https://m.example.com/act/draw。若页面有参数(如?uid=123),必须完整带上,微信校验时会比对这个URL。


3. 设计可审计的中奖逻辑:用Redis Lua脚本保证“查库存-扣库存-发奖”原子性

大转盘核心不是转动画,而是中奖结果的确定性与一致性。常见错误是“先查库存>0,再扣减库存,最后发奖”——在高并发下必然超发。必须用原子操作。微信大转盘典型奖品结构:一等奖(iPhone,库存10)、二等奖(充电宝,库存500)、三等奖(优惠券,库存10000)、谢谢参与(无限)。我们需要一个方案:同一用户限中一次、同一IP限中N次、库存扣减不可逆、中奖记录可追溯。

3.1 奖品配置表设计:支持动态调整与权重控制

用MySQL存储奖品基础信息,关键字段:

字段类型说明
idBIGINT PK奖品ID
prize_nameVARCHAR(50)奖品名称
stockINT总库存(-1表示无限)
weightINT权重值,用于概率计算(如一等奖weight=1,二等奖weight=10,三等奖weight=100)
max_per_userINT单用户最多中几次(0表示不限)
max_per_ipINT单IP最多中几次(0表示不限)
is_enabledTINYINT是否启用(1启用,0停用)

为什么不用纯随机数?因为运营需要调控中奖率。比如活动初期想拉新,可临时调高“优惠券”权重;后期冲销量,可提高“满减券”权重。权重法比Random.nextInt(100)硬编码更灵活。

3.2 Redis Lua脚本实现原子扣奖:一行代码解决超发

将“检查用户/IP中奖次数 + 检查库存 + 扣减库存 + 记录中奖”封装进Lua脚本,由Redis单线程执行,彻底避免竞态。脚本接收5个参数:prizeId,openId,ip,userLimitKey,ipLimitKey,返回结果码(0=成功,1=用户已达上限,2=IP已达上限,3=库存不足,4=奖品已禁用)。

-- check_and_decr_stock.lua local prizeId = tonumber(ARGV[1]) local openId = ARGV[2] local ip = ARGV[3] local userLimitKey = ARGV[4] local ipLimitKey = ARGV[5] -- 1. 检查奖品是否启用 local isEnabled = redis.call('HGET', 'prize:info:'..prizeId, 'is_enabled') if isEnabled ~= '1' then return 4 end -- 2. 检查用户中奖次数 local userCount = tonumber(redis.call('GET', userLimitKey)) or 0 local maxPerUser = tonumber(redis.call('HGET', 'prize:info:'..prizeId, 'max_per_user')) if maxPerUser > 0 and userCount >= maxPerUser then return 1 end -- 3. 检查IP中奖次数 local ipCount = tonumber(redis.call('GET', ipLimitKey)) or 0 local maxPerIp = tonumber(redis.call('HGET', 'prize:info:'..prizeId, 'max_per_ip')) if maxPerIp > 0 and ipCount >= maxPerIp then return 2 end -- 4. 检查并扣减库存(-1表示无限库存) local stock = tonumber(redis.call('HGET', 'prize:info:'..prizeId, 'stock')) if stock ~= -1 and stock <= 0 then return 3 end if stock ~= -1 then redis.call('HINCRBY', 'prize:info:'..prizeId, 'stock', -1) end -- 5. 记录用户/IP中奖(设置过期时间,如24小时) redis.call('INCR', userLimitKey) redis.call('EXPIRE', userLimitKey, 86400) redis.call('INCR', ipLimitKey) redis.call('EXPIRE', ipLimitKey, 86400) return 0

关键点:userLimitKey格式为user:prize:{prizeId}:{openId},ipLimitKey为ip:prize:{prizeId}:{ip},确保键空间隔离;HINCRBY对库存扣减是原子的;所有INCR操作都配EXPIRE,避免长期占用内存。

3.3 Java调用Lua脚本:封装成可复用的服务

@Service public class PrizeDrawService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private JdbcTemplate jdbcTemplate; private DefaultRedisScript<Long> checkAndDecrScript; @PostConstruct public void init() { checkAndDecrScript = new DefaultRedisScript<>(); checkAndDecrScript.setScriptSource(new ResourceScriptSource( new ClassPathResource("lua/check_and_decr_stock.lua"))); checkAndDecrScript.setResultType(Long.class); } public DrawResult drawPrize(Long prizeId, String openId, String ip) { String userKey = "user:prize:" + prizeId + ":" + openId; String ipKey = "ip:prize:" + prizeId + ":" + ip; Long result = redisTemplate.execute( checkAndDecrScript, Collections.emptyList(), prizeId.toString(), openId, ip, userKey, ipKey ); switch (result.intValue()) { case 0: // 中奖成功,记录数据库 saveWinRecord(prizeId, openId, ip); return DrawResult.success(prizeId); case 1: return DrawResult.fail("用户已达中奖上限"); case 2: return DrawResult.fail("IP已达中奖上限"); case 3: return DrawResult.fail("奖品库存不足"); case 4: return DrawResult.fail("奖品已暂停发放"); default: return DrawResult.fail("未知错误"); } } private void saveWinRecord(Long prizeId, String openId, String ip) { String sql = "INSERT INTO lottery_record (prize_id, open_id, ip, create_time) VALUES (?, ?, ?, ?)"; jdbcTemplate.update(sql, prizeId, openId, ip, new Date()); } }

参数说明:redisTemplate.execute()第一个参数是脚本对象,第二个是KEYS列表(本例为空),第三个是ARGV参数列表(按Lua脚本ARGV[1]到ARGV[5]顺序传入)。DrawResult是自定义返回对象,包含是否成功、奖品ID、提示信息。


4. 防刷与风控:基于用户行为特征的实时拦截策略

单纯靠IP和OpenID限制不够——黄牛用代理池、模拟器批量请求。必须叠加行为分析:同一设备频繁点击、请求间隔过短、无有效微信授权直接调抽奖接口。我们采用三级防御:网关层基础过滤、服务层规则引擎、离线分析补漏。

4.1 网关层限流:用Spring Cloud Gateway按OpenID限流

在API网关对/api/draw接口做精准限流,单位时间内同一OpenID最多请求N次:

# application.yml spring: cloud: gateway: routes: - id: lottery_route uri: lb://lottery-service predicates: - Path=/api/draw filters: - name: RequestRateLimiter args: key-resolver: "#{@openIdKeyResolver}" redis-rate-limiter.replenishRate: 5 # 每秒补充5个令牌 redis-rate-limiter.burstCapacity: 10 # 最多允许10个令牌
@Configuration public class KeyResolverConfig { @Bean public KeyResolver openIdKeyResolver() { return exchange -> Mono.justOrEmpty( exchange.getRequest().getQueryParams().getFirst("openId")) .defaultIfEmpty("anonymous"); } }

为什么用OpenID而非IP?因为微信环境下OpenID是用户唯一标识,比IP更精准;burstCapacity设为10意味着突发流量可容忍,避免误杀正常用户。

4.2 服务层风控规则:用Drools实现可配置规则引擎

将风控逻辑从代码中解耦,用Drools规则文件定义:

// src/main/resources/rules/risk.drl package com.example.lottery.rule import com.example.lottery.dto.DrawRequest; import com.example.lottery.dto.RiskResult; rule "高频点击拦截" when $req: DrawRequest( $openId: openId, $ip: ip, $time: timestamp ) $count: Number(intValue > 5) from accumulate( $log: AccessLog(openId == $openId, ip == $ip, timestamp > $time - 60000), count($log) ) then $req.setRiskResult(new RiskResult(true, "高频点击")); end rule "无授权访问拦截" when $req: DrawRequest( openId == null || openId == "" ) then $req.setRiskResult(new RiskResult(true, "未授权访问")); end

Java中调用规则:

@Service public class RiskCheckService { @Autowired private KieSession kieSession; public boolean checkRisk(DrawRequest request) { kieSession.insert(request); kieSession.fireAllRules(); return request.getRiskResult() != null && request.getRiskResult().isBlocked(); } }

优势:规则可热更新,运营人员修改.drl文件重启服务即可生效,无需发版;accumulate函数支持复杂事件聚合,如“1分钟内同一OpenID请求超5次”。

4.3 行为日志埋点:为离线分析提供原始数据

所有抽奖请求必须记录完整上下文,存入Elasticsearch便于Kibana分析:

{ "timestamp": "2023-10-05T14:22:31.123Z", "openId": "oABC123xyz...", "ip": "119.123.45.67", "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.43(...)", "device_id": "a1b2c3d4e5f6", // 前端通过localStorage生成的设备指纹 "request_id": "req_abc123...", "status": "success", "prize_id": 101, "cost_time_ms": 127 }

提示:device_id不能依赖微信的deviceId(部分安卓机型不返回),建议前端用navigator.userAgent + screen.width + screen.height + Date.now()哈希生成,服务端只记录不校验。


5. 常见问题排查:5条真实踩过的坑,每条都附定位命令和修复方案

做微信大转盘最痛苦的不是写代码,而是线上出问题时找不到根因。以下是我在某电商节活动中血泪总结的5个高频问题,按现象→原因→解决三步给出可立即执行的排查指令。

5.1 现象:前端wx.config:ok但wx.chooseImage等接口报permission denied

原因:jsapi_ticket生成时用的access_token与调用JS接口时微信服务器校验的access_token不一致。常见于多实例部署时,各节点缓存了不同access_token,而jsapi_ticket又依赖access_token,导致签名失效。

定位命令:

# 查看各节点缓存的access_token(假设用Redis存储) redis-cli -h 10.0.1.100 KEYS "wechat:access_token:*" # 节点A redis-cli -h 10.0.1.101 KEYS "wechat:access_token:*" # 节点B

若返回不同key,说明token未共享。

解决:强制所有节点共用Redis缓存access_token和jsapi_ticket,删除本地ConcurrentHashMap缓存,改用StringRedisTemplate.opsForValue().getAndSet()。

5.2 现象:中奖记录数据库有数据,但用户说没收到微信模板消息

原因:微信模板消息发送接口返回{"errcode":0,"errmsg":"ok"},但实际用户未收到。根本原因是template_id在微信后台未通过审核,或data字段中某个key的value为空字符串(微信要求非空)。

定位命令:

-- 查数据库中最近10条失败记录(假设模板发送日志表为template_log) SELECT * FROM template_log WHERE status = 'fail' ORDER BY create_time DESC LIMIT 10;

检查response_body字段是否含"errmsg":"template not exist"。

解决:登录微信公众号后台→功能→模板消息,确认template_id状态为“已通过”;Java发送前校验data每个value非空:

data.entrySet().removeIf(entry -> StringUtils.isBlank(entry.getValue().getValue()));

5.3 现象:Redis Lua脚本返回3(库存不足),但数据库prize_info.stock显示还有100

原因:Lua脚本中HGET prize:info:101 stock读取的是Redis哈希中的库存,而数据库库存未同步。常见于运营后台直接改了DB库存,但忘了同步Redis。

定位命令:

# 对比DB和Redis库存 mysql -u root -p -e "SELECT stock FROM prize_info WHERE id=101;" redis-cli HGET "prize:info:101" stock

解决:建立库存同步机制。当运营修改DB库存时,触发MQ消息,消费者执行:

redisTemplate.opsForHash().put("prize:info:101", "stock", "100");

5.4 现象:同一用户多次抽奖,user:prize:101:openid_xxx的值为1,但实际抽了3次

原因:INCR命令未配EXPIRE,或EXPIRE执行失败(如Redis集群模式下INCR和EXPIRE不在同一slot)。user:prize:101:openid_xxx永久存在,导致后续永远返回1。

定位命令:

# 检查key是否存在且有过期时间 redis-cli TTL "user:prize:101:openid_xxx" # 若返回-1,说明没过期;返回-2,说明key不存在

解决:Lua脚本中INCR后必须紧跟EXPIRE,且用EVAL一次性执行(本例已做到);生产环境用Redis单机或Cluster模式时,确保key哈希槽一致(本例key含prizeId,天然分散)。

5.5 现象:Drools规则不生效,kieSession.fireAllRules()后RiskResult仍为null

原因:Drools默认使用PHREAK算法,要求Fact对象的字段必须是public或有getter方法,且@EqualsAndHashCode注解未排除riskResult字段,导致规则匹配失败。

定位命令:

// 在规则触发前打印Fact对象 System.out.println("Fact: " + request); // 查看riskResult是否为null

解决:为DrawRequest添加Lombok注解,并确保riskResult字段不参与equals比较:

@EqualsAndHashCode(exclude = "riskResult") public class DrawRequest { ... }

6. 进阶技巧:用“影子库”做AB测试,让运营随时切换中奖策略而不发版

上线大转盘最怕什么?不是技术故障,而是运营策略错了——比如把一等奖权重设太高,半小时发完10台iPhone,后面用户全抽“谢谢参与”,投诉暴增。传统做法是改代码、发版、回滚,耗时30分钟以上。更好的方案是影子库+动态路由:同一套代码,根据请求头或参数,自动切到不同奖品配置库,实现策略秒级切换。

6.1 构建双配置库:production与shadow

在MySQL中创建两个完全相同的奖品配置表:

  • prize_info_production:当前线上使用的主库,所有SELECT默认走这里
  • prize_info_shadow:影子库,运营在后台修改此表,不影响线上
CREATE TABLE prize_info_shadow LIKE prize_info_production; INSERT INTO prize_info_shadow SELECT * FROM prize_info_production;

关键设计:两张表结构、索引、数据完全一致,仅表名不同。避免JOIN或复杂查询,确保切换无性能差异。

6.2 动态数据源路由:基于请求头决定读哪个库

用AbstractRoutingDataSource实现数据源动态切换:

public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { String strategy = RequestContext.getCurrentContext().getRequest() .getHeader("X-Prize-Strategy"); // 前端请求头传strategy=shadow return StringUtils.defaultString(strategy, "production"); } } @Configuration public class DataSourceConfig { @Bean @Primary public DataSource dataSource() { DynamicDataSource dynamicDataSource = new DynamicDataSource(); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("production", productionDataSource()); targetDataSources.put("shadow", shadowDataSource()); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(productionDataSource()); return dynamicDataSource; } }

前端调用示例:抽奖时加请求头X-Prize-Strategy: shadow,后端自动读prize_info_shadow表;不加则读production。运营可在Kibana中实时对比两库中奖率,确认效果后再将shadow同步到production。

6.3 奖品权重热更新:用Redis Pub/Sub通知所有节点

当运营在后台修改prize_info_shadow.weight后,需通知所有Java节点刷新本地权重缓存(避免查DB)。用Redis Pub/Sub广播:

@Component public class PrizeWeightListener { @Autowired private StringRedisTemplate redisTemplate; @PostConstruct public void init() { redisTemplate.getConnectionFactory().getConnection() .subscribe((message, pattern) -> { String payload = new String(message.getBody()); if ("prize_weight_updated".equals(new String(pattern))) { refreshWeightCache(); // 清空本地ConcurrentHashMap缓存 } }, "prize_weight_updated"); } public void updateWeight(Long prizeId, Integer newWeight) { // 更新shadow表 jdbcTemplate.update("UPDATE prize_info_shadow SET weight=? WHERE id=?", newWeight, prizeId); // 广播事件 redisTemplate.convertAndSend("prize_weight_updated", prizeId.toString()); } }

价值:运营改一个权重,1秒内全量节点生效,无需重启。某次活动中,运营发现“优惠券”中奖率偏低,5分钟内完成权重从100调至300,中奖率从12%升至35%,全程无用户感知。

我带过的三个项目里,有两次翻车都发生在“以为签名没问题,其实是URL没传对”这种低级错误上——所以现在我的习惯是:每次上线前,用curl手动调一遍/api/wx/jsapi-config?url=https://xxx.com/act/draw,把返回的nonceStr、timestamp、signature复制到微信调试工具里粘贴校验,绿勾出来才敢放行。技术没有玄学,只有把每个环节的输入输出钉死。希望帮到你。

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

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

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

立即咨询