简介面向年会场景的抽奖互动Web小程序基于JSP实现无需数据库即可运行数据以静态方式存储共包含两个页面九宫格抽奖和转盘抽奖。其核心规则是先由九宫格抽出一名幸运者免发红包再通过转盘抽取100、200、300、500、1000元五种红包同时限制每人红包总额不超过2000元满足年会现场“抽人发红包”的常见互动需求。资源包共74个文件压缩包约2.25MB主要包含png、js、css、gif等前端展示资源以及jar、java、jsp等后端逻辑文件方便快速了解页面与抽奖实现。目前已有940人学习下载适合初级Web开发者和年会策划者参考使用。通过源码可以掌握免数据库的前端静态数据管理方式、九宫格与转盘抽奖的业务实现以及红包上限的校验思路便于按自身年会场景快速改造部署。1. 年会抽奖九宫格转盘先看清两类交互的差异再动手年会前一周被拉去做抽奖小程序的场景很多人都不陌生既要九宫格跑马灯又要转盘奖品池还得是同一个。先把结论放这儿九宫格和转盘看起来是两个组件但抽奖引擎可以完全共用真正要拆开处理的是「动画表现」和「结果判定」这两层。九宫格适合多人快速滚动抽奖转盘适合单次仪式感强的开奖但两者都依赖一套确定性的服务端抽奖逻辑而不是前端随机。这篇笔记适合小程序开发者、接内部项目的外包工程师以及想在活动上线前少踩几个坑的人。2. 共用一套抽奖引擎把动画、概率、库存拆成三层2.1 奖品配置不用百分比用权重更省心先把数据模型定住后面所有代码都围着它转。奖品表我一般这样设计id、name、weight、inventory、remain、is_active。这里最关键的是用weight权重而不是percent百分比。因为权重是相对值你加一个奖品、改一个概率不需要把所有百分比重算一遍。{ prizes: [ { id: 1, name: 一等奖 手机, weight: 1, inventory: 1, remain: 1 }, { id: 2, name: 二等奖 耳机, weight: 3, inventory: 3, remain: 3 }, { id: 3, name: 三等奖 抱枕, weight: 10, inventory: 20, remain: 20 }, { id: 4, name: 阳光普照, weight: 86, inventory: 999, remain: 999 } ] }逻辑说明四个奖品的权重加起来是 100一等奖实际概率就是 1%阳光普照是 86%。后端每次抽奖前先过滤掉remain 0和is_active false的奖品再把剩余权重求和。这样配置端加奖品时只填一个相对权重就行不会出现「改了一个百分比总和不是 100」的尴尬。参数说明weight用整数不要用小数。整数在浮点运算里误差可控日志里一眼能看出量级。inventory是初始库存remain是剩余库存这两个字段分开存是为了方便复盘「配置了多少、实际发了多少」。我在实际项目里还加过daily_limit字段用来限制单人每日抽奖次数如果你有类似需求在奖品表旁边再加一张用户抽奖记录表做计数即可。2.2 结果判定必须放服务端前端只负责「演」这里的核心原则是动画是表演结果是判决。前端Math.random()只适合做 UI 演示不适合做真抽奖。原因有两个一是前端代码可以被抓包改写二是不同客户端 JS 引擎的随机数质量参差不齐真出问题没法追溯。服务端抽奖算法用线性扫表法就够了。奖品数量在年会场景一般不超过 15 个没必要上二分或别名算法。function drawPrize(activePrizes) { const totalWeight activePrizes.reduce((sum, p) sum p.weight, 0); let random Math.random() * totalWeight; for (const prize of activePrizes) { random - prize.weight; if (random 0) { return prize; } } // 理论上不会走到这里兜底返回最后一个 return activePrizes[activePrizes.length - 1]; }逻辑说明random落在[0, totalWeight)区间内每个奖品占的区间长度就是它的权重值。循环里每经过一个奖品就把random减去对应权重第一次小于 0 就命中。这个算法的特点是没有预先归一化直接拿原始权重算代码直观。参数说明activePrizes必须是已经过滤掉库存为 0 的数组否则会出现抽中但没货的情况。抽奖顺序建议在调用drawPrize之前先做一次「库存预扣」把remain减掉再写记录失败就回滚。2.3 Canvas 转盘绘制与旋转用缓动函数控制停止位置转盘是小程序里比较吃细节的部分。我用 Canvas 2D 绘制扇形每次抽奖重新画指针位置而不是旋转整个 canvas 画布。因为旋转画布需要处理ctx.rotate()后的文字朝向容易把文字转倒。先看绘制扇区的核心函数function drawWheel(ctx, centerX, centerY, radius, prizes, colors) { const count prizes.length; const sectorAngle (Math.PI * 2) / count; const startOffset -Math.PI / 2; // 让第一个扇区从正上方开始 prizes.forEach((prize, index) { const startAngle startOffset index * sectorAngle; const endAngle startAngle sectorAngle; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.closePath(); ctx.fillStyle colors[index % colors.length]; ctx.fill(); // 绘制奖品文字放在扇区中线位置 ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(startAngle sectorAngle / 2); ctx.textAlign right; ctx.fillStyle #fff; ctx.font 14px sans-serif; ctx.fillText(prize.name, radius - 16, 5); ctx.restore(); }); }逻辑说明startOffset -Math.PI / 2是为了把 0 度角从三点钟方向转到十二点钟方向指针默认指向正上方。文字绘制时先translate到圆心再rotate到扇区中线fillText的 x 坐标用radius - 16让文字收敛到圆盘边缘内。旋转动画的关键参数是时长和缓动。转盘我一般给 4000ms九宫格每个格子高亮间隔 150ms。两者都用 easeOutCubic 缓动效果是「快速启动、慢速停止」观感最自然function animateRotation(duration, onProgress) { const startTime Date.now(); return new Promise((resolve) { function step() { const elapsed Date.now() - startTime; const progress Math.min(elapsed / duration, 1); const eased 1 - Math.pow(1 - progress, 3); onProgress(eased); if (progress 1) { requestAnimationFrame(step); } else { resolve(); } } step(); }); }逻辑说明progress是线性时间进度eased是缓动后的旋转进度。onProgress(eased)每次回调都会把当前扇区角度重算并重绘。requestAnimationFrame的好处是跟随屏幕刷新率小程序页面切后台时自动暂停不会像setInterval那样在后台堆叠回调。参数说明duration单位是毫秒。转盘停在目标扇区的角度计算式为总圈数 * 2π 目标扇区起始角 - 半扇区角其中为什么要减半扇区角第 4 章的 4.2 节会专门讲这是最容易翻车的位置。3. 九宫格实现与接口对接前端高亮与后端返回的协同3.1 九宫格跑马灯用索引数组代替 if-else 判断九宫格布局是 3x3中间一个按钮四周 8 个格子。高亮顺序是顺时针上、右上、右、右下、下、左下、左、左上。很多人会直接写 8 个 if 判断但这东西用一个索引映射数组就解决了const GRID_ORDER [0, 1, 2, 5, 8, 7, 6, 3]; let currentIndex 0; let timer null; function startRolling(onHighLight) { currentIndex 0; timer setInterval(() { onHighLight(GRID_ORDER[currentIndex]); currentIndex (currentIndex 1) % GRID_ORDER.length; }, 150); } function stopRolling() { clearInterval(timer); timer null; }逻辑说明GRID_ORDER存的是 DOM 节点在布局里的位置序号按顺时针排列。每次 tick 把当前格子高亮然后currentIndex 1取模实现循环。中间按钮不用管它永远不参与高亮。参数说明150ms 是单个格子停留时间换算下来一轮 1.2 秒。如果要拖节奏感可以把这个值改成 180ms但超过 200ms 观感会变拖沓。暂停函数必须清定时器否则用户再次点击时会出现两个定时器叠加高亮速度翻倍。3.2 高亮何时跑先请求还是先动画九宫格最容易踩的问题是「动画跑完了结果还没回来」。如果你先启动动画再发请求动画 1.2 秒转完接口 2 秒才返回高亮停在一个随机格子用户已经开始起哄了。反过来做先发请求拿结果再启动动画。请求期间按钮转菊花拿到prizeIndex后再跑跑马灯停在指定格。let isDrawing false; async function onDrawTap() { if (isDrawing) return; isDrawing true; drawButton.disabled true; try { const res await requestDraw(); await playRollingToIndex(res.prizeIndex); showPrizeModal(res.prizeName); } catch (err) { wx.showToast({ title: 网络开小差了请重试, icon: none }); } finally { isDrawing false; drawButton.disabled false; } }逻辑说明isDrawing是锁保证同时只有一个抽奖流程在跑。requestDraw()对应后端接口拿到结果后用prizeIndex驱动跑马灯从当前currentIndex走到目标格子走完再弹窗。参数说明prizeIndex必须由后端返回对应奖品在奖品数组里的下标。前端拿这个下标算「还差几步到目标格」。这里有个隐藏设计后端给的不应该是「奖品 id」而是「前端动画格子下标」因为后端不知道前端格子布局顺序。如果后端只给奖品 id前端还得自己查表一旦布局顺序和奖品配置顺序不一致就会停错位置。3.3 接口字段设计一次响应把动画和目标都交代清楚抽奖接口的返回结构我固定用下面这套前端拿到后不需要二次查询{ code: 0, message: ok, data: { requestId: 8f6a2c9e, prizeId: 2, prizeName: 二等奖 耳机, prizeIndex: 4, inventoryLeft: 2 } }字段说明requestId是本次抽奖的唯一流水号前端生成后传给后端同一流水号重复提交不会产生两条订单这是防重复发奖的兜底。prizeIndex是前端动画要停的格子下标服务端得知道前端九宫格或转盘的奖品排列顺序。inventoryLeft返回给前端是为了在活动页展示「还剩 X 份」同时如果返回 0前端可以提前置灰该奖品。这个接口同时服务九宫格和转盘。转盘拿prizeIndex去算旋转目标角度九宫格拿prizeIndex去算高亮步数两者的差异只在动画层接口层完全一致。这也是为什么一开始说「共用一个抽奖引擎」——业务上省一半开发量排查时只需要盯一条链路。4. 避坑抽奖项目最常见的五个翻车现场4.1 现象一动画跑完了奖品没回来现象转盘减速到停止弹窗显示「网络异常」用户以为没抽中再点一次结果两次都中奖了。原因请求已经到达服务端但响应超过前端超时时间。前端catch把这次请求当成失败处理实际上订单已经生成。解决抽奖接口必须实现幂等。前端每次抽奖生成requestId服务端用(userId, requestId)建唯一索引重复请求直接返回第一次的结果。前端超时后不要直接解锁按钮先带requestId调一次查询接口看订单是否已生成再决定是提示中奖还是允许重抽。4.2 现象二转盘指针指向和实际奖品对不上现象Toast 弹的是「三等奖」但指针明显停在「二等奖」扇区边缘现场同事拍照留证场面很尴尬。原因旋转目标角度只乘了扇区角度没把指针本身和扇区中线的偏移算进去。比如第一个扇区从 -90° 开始指针在 0° 方向两者天然差了半个扇区角。解决目标角度公式补一项偏移修正const totalRounds 6; const targetAngle totalRounds * Math.PI * 2 prizeIndex * sectorAngle sectorAngle / 2; sectorAngle / 2让每个扇区的中线对准正上方指针。这条我在本地测试时盲测了三轮都没发现问题真机一跑就露馅——因为模拟器里 canvas 坐标和真机渲染有细微差异。从那以后转盘类动画我都在真机上验证停止位置。4.3 现象三连点按钮导致奖品多发现象手速快的用户连续点抽奖按钮后台出现两条订单扣了两次库存。原因前端锁只在请求返回后释放但两次 tap 事件间隔极短isDrawing还没来得及置 true 就已经发起了第二个请求。解决前端按钮用disabled属性锁同时给抽奖接口加幂等约束。前端requestId每次点击生成但事件处理函数里第一步就是检查isDrawing这个锁的赋值必须在任何await之前同步完成。最稳的做法是后端唯一索引兜底前端锁只是降低无效请求量后端幂等才是真正的后悔药。4.4 现象四库存扣了但订单没生成现象后台看到某个奖品remain变成了 0但抽奖记录表里中奖数量只有 5 条库存配置却是 10对不上账。原因扣库存和写订单是两条 SQL中间抛异常时没有回滚。或者扣库存时用的是「先查再改」逻辑先SELECT remain判断大于 0再UPDATE并发场景下两个请求同时通过判断。解决用一条带条件的更新语句原子扣库存UPDATE prize SET remain remain - 1 WHERE id ? AND remain 0;这条 SQL 影响行数为 1 表示扣成功为 0 表示库存不足。扣库存和插订单放在同一个事务里订单插入失败就回滚扣库存操作。排查顺序先看异常日志里有没有事务回滚标记再看奖品表的remain和订单表数量是否一致。4.5 现象五测试环境 100 次里 80 次中一等奖现象测试同学反馈「一等奖概率设置成 1%实测 100 次中了 80 次」配置没问题但结果离谱。原因常见有两种。一种是前端本地Math.random()参与结果判定每次刷新页面随机种子变化短样本下分布极其不均匀。另一种是服务端用Math.random()但代码写成了weight / totalWeight的整除比如weight是 1、totalWeight是 100整数除法直接得到 0导致某些奖品永远不中另一些概率异常高。解决抽奖结果判定只信服务端且随机源至少用crypto.randomInt()这类强随机。概率验证不要靠肉眼手点 100 次写一个脚本直接调用服务端抽奖函数跑 1 万次统计各奖品命中比例误差在 ±1% 内再放行。其他常见误用还包括把权重写成小数导致浮点累计误差以及库存为 0 的奖品没过滤就参与抽奖——这两个都在代码审查阶段就能发现。5. 进阶抽奖日志回放用数据验证概率不是玄学抽奖功能上线后最怕的不是功能出错而是「概率说不清楚」。这时候建立一个抽奖日志回放机制就很有必要了。我的做法是前端每次抽奖成功后把一条{ requestId, prizeId, prizeName, timestamp }记录写入本地存储活动结束后导出成 JSON 文件用一段小脚本统计各个奖品实际命中次数const fs require(fs); const logs JSON.parse(fs.readFileSync(./lottery_log.json, utf8)); const total logs.length; const stats {}; logs.forEach((log) { stats[log.prizeId] (stats[log.prizeId] || 0) 1; }); Object.entries(stats).forEach(([prizeId, count]) { const percent (count / total * 100).toFixed(2); console.log(prize ${prizeId}: ${count} 次占比 ${percent}%); });逻辑说明这段脚本把 JSON 日志按prizeId分组统计输出每个奖品的实际中奖比例。配合服务端的权重配置可以直接对比「配置概率」和「实际结果」的偏差。参数说明requestId必须带在日志里因为幂等重试会产生重复的响应但只有第一条是真实订单。统计前先用Set去重保证每条订单只算一次。我个人的一个习惯是每次抽奖活动上线前强制跑一遍 10000 次模拟抽奖把统计结果和权重配置打印出来对比误差在 1% 以内才算过。之前有一次我把权重写成了占位符百分比没换算本地测了 500 次中奖率异常靠这个模拟器直接抓了出来。从那以后不管活动多急这个验证流程都不会省。希望这篇笔记能帮你把年会抽奖项目做得更稳也欢迎在实践中继续踩坑回来交流。本文还有配套的精品资源点击获取