ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

幸运九宫格抽奖系统实战:抽奖码、权重算法与防超发设计

幸运九宫格抽奖系统实战:抽奖码、权重算法与防超发设计 简介这是一套基于PHP实现的幸运九宫格抽奖码抽奖系统源码面向Web开发初学者、计算机专业毕业设计学生以及需要快速搭建互动抽奖活动的开发者。系统围绕抽奖码生成、验证与随机抽取获胜者展开涵盖前端界面、后台管理与数据库操作适合作为课程设计、论文案例或小型商业活动的实践素材。压缩包共699个文件约9MB以gif、png、jpg等图片资源和js、css前端文件为主另含19个php核心脚本、14个db数据文件、1个sql建库脚本及少量音视频与字体资源目录中可见后台管理、静态资源与配置模块结构完整便于二次修改。目前已有130人学习下载。通过阅读core.php、index.php、config.php等关键文件读者可理解抽奖码管理、随机算法与数据库交互的完整流程并借助说明文档快速完成环境部署与功能调试是学习PHP Web应用开发与项目管理的实用参考。1. 幸运九宫格抽奖系统从抽奖码到九宫格动画一套能跑通的完整链路运营活动里最常被临时加需求的就是抽奖页。产品经理一句“做个九宫格抽奖用户输抽奖码就能抽”留给开发的时间往往只有两三天。幸运九宫格抽奖码抽奖系统 v1.1 这个标题拆开看是三个东西九宫格转盘 UI、抽奖码作为参与凭证、以及背后决定谁中奖的抽奖逻辑。它解决的核心问题是——让用户凭一串码进入抽奖点击后九宫格高亮轮转最后停在一个格子上给出结果同时保证奖品库存不超发、同一码不能重复抽。适合谁看做过 H5 活动页的前端、写过后端发奖接口的工程师以及需要快速搭一套抽奖 Demo 验证玩法的人。下面按“先想清楚再动手”的顺序把选型、实现、参数和踩坑一次讲透。2. 抽奖码与九宫格先定数据模型再写动画2.1 抽奖码到底存什么为什么不能只存一个字符串很多人第一版会把抽奖码当成一个简单的字符串用户输入后去数据库SELECT一下存在就放行。这个做法在活动量小的时候没问题但一旦要控制“每个码只能抽一次”“不同码对应不同奖池”“码有有效期”就必须把码设计成一条有状态的记录。常见做法是建一张lottery_code表字段至少包含码本身唯一索引、状态未使用/已使用/已过期、绑定的活动 ID、使用时间、以及抽中的奖品 ID。这样每次抽奖就是一次带条件的更新而不是先查后写——先查后写在并发下必然出现同一个码被抽两次。CREATE TABLE lottery_code ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) NOT NULL, activity_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0未使用 1已使用 2已过期 prize_id INT DEFAULT NULL, used_at DATETIME DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_code (code), KEY idx_activity_status (activity_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;上面这段建表语句里uk_code唯一索引是防止重复发码的第一道闸idx_activity_status是为了按活动批量查未使用的码时走索引。status用 TINYINT 而不是字符串是为了更新时能用UPDATE ... WHERE status0这种原子操作。prize_id允许为空因为未抽奖时它没有值。注意used_at和created_at分开方便后面做“码在什么时间段被消耗”的统计。2.2 九宫格的格子顺序和奖品映射别等 UI 写完才想九宫格看起来是 3×3 共 9 个格子但中间那格通常放“抽奖按钮”所以实际参与轮转的是 8 个格子。这 8 个格子的视觉顺序和数组下标顺序往往不一致——视觉上是顺时针绕一圈数组里可能是按行存储。如果不提前把映射关系定好动画转到第 5 格停住时你根本不知道对应的是哪个奖品。我一般会定义一个长度为 8 的奖品数组下标 0 到 7 对应从左上角开始顺时针的格子中间按钮单独处理。// 九宫格奖品映射下标0~7对应顺时针8个格子中间为按钮 const prizeGrid [ { id: 101, name: 一等奖, stock: 1 }, { id: 102, name: 二等奖, stock: 5 }, { id: 103, name: 谢谢参与, stock: 9999 }, { id: 104, name: 三等奖, stock: 20 }, { id: 105, name: 谢谢参与, stock: 9999 }, { id: 106, name: 四等奖, stock: 50 }, { id: 107, name: 谢谢参与, stock: 9999 }, { id: 108, name: 五等奖, stock: 100 } ]; // 顺时针转动时下一个高亮格子的下标计算 function nextIndex(current) { return (current 1) % 8; }这段代码里prizeGrid的顺序就是视觉顺序nextIndex用取模保证从 7 回到 0。stock字段是库存后面抽奖逻辑要用。注意“谢谢参与”也占一个格子它的库存设得很大这样抽奖算法不用为“不中奖”单独写分支——不中奖就是抽中了一个库存无限的奖品。这个设计能省掉很多 if-else。2.3 抽奖码校验接口的最小实现用户输入抽奖码后前端要调一个校验接口确认码有效且未使用。这个接口不能只返回 true/false还要返回活动信息和剩余抽奖次数如果允许多次。下面是一个用 Node.js 写的简化版本核心是原子更新。// POST /api/verify-code { code: ABC123 } async function verifyCode(req, res) { const { code } req.body; // 原子更新只有status0时才更新为1避免并发重复使用 const [result] await db.execute( UPDATE lottery_code SET status 1, used_at NOW() WHERE code ? AND status 0, [code] ); if (result.affectedRows 0) { // 没更新到说明码不存在或已被使用 return res.json({ ok: false, msg: 抽奖码无效或已使用 }); } // 更新成功查回活动信息 const [rows] await db.execute( SELECT activity_id FROM lottery_code WHERE code ?, [code] ); res.json({ ok: true, activityId: rows[0].activity_id }); }这里的关键是UPDATE ... WHERE status 0这一句。它把“检查”和“占用”合并成一个原子操作数据库层面保证只有一个请求能把 status 从 0 改成 1。affectedRows为 0 就说明码无效或已被抢先用掉。注意这个接口一旦调用成功码就被标记为已使用所以前端应该在用户点击“开始抽奖”时才调它而不是页面一加载就调。参数方面code要做长度和字符集校验防止 SQL 注入用参数化查询已经防了大部分。3. 抽奖算法与库存扣减概率、权重和防超发3.1 按权重抽奖的三种写法为什么我选别名法抽奖算法常见的有三种一是给每个奖品分配一个概率区间生成随机数落区间二是按权重累加后二分查找三是别名法Alias Method。前两种在奖品数量少的时候够用但九宫格通常有 8 个格子其中“谢谢参与”可能占多个权重差异大。我一般用第二种的简化版——累加权重后生成随机数因为实现简单且不容易出错。// 按权重抽奖prizes为奖品数组weight为权重 function drawByWeight(prizes) { const totalWeight prizes.reduce((sum, p) sum p.weight, 0); let random Math.random() * totalWeight; for (const prize of prizes) { random - prize.weight; if (random 0) { return prize; } } return prizes[prizes.length - 1]; // 兜底 } // 示例一等奖权重1二等奖权重5谢谢参与权重94 const pool [ { id: 101, weight: 1 }, { id: 102, weight: 5 }, { id: 103, weight: 94 } ];totalWeight是所有奖品权重之和Math.random()生成 0 到 1 之间的小数乘以总权重后得到一个 0 到 totalWeight 之间的随机数。然后依次减去每个奖品的权重减到小于等于 0 时命中的就是当前奖品。这个写法要求权重是正整数且数组顺序固定。注意random 0而不是 0是为了让边界值也能命中。兜底返回最后一个奖品是防止浮点数误差导致循环走完还没返回。3.2 库存扣减必须和抽奖在同一个事务里抽奖算法决定了“抽中什么”但能不能发出去还要看库存。如果先抽奖再扣库存中间可能被其他请求把库存抢光导致超发。正确做法是把抽奖和扣库存放在一个数据库事务里并且扣库存用UPDATE ... WHERE stock 0这种条件更新。async function drawAndReduceStock(activityId, code) { const conn await db.getConnection(); try { await conn.beginTransaction(); // 1. 锁定该活动的奖品池这里简化为查库存 const [prizes] await conn.execute( SELECT id, stock, weight FROM prize WHERE activity_id ? FOR UPDATE, [activityId] ); // 2. 按权重抽奖 const won drawByWeight(prizes); // 3. 扣库存条件更新防止超发 const [updateResult] await conn.execute( UPDATE prize SET stock stock - 1 WHERE id ? AND stock 0, [won.id] ); if (updateResult.affectedRows 0) { throw new Error(库存不足); } // 4. 记录中奖结果 await conn.execute( UPDATE lottery_code SET prize_id ? WHERE code ?, [won.id, code] ); await conn.commit(); return won; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }这段代码里FOR UPDATE是行级锁锁住该活动的奖品行防止其他事务同时修改。UPDATE ... WHERE stock 0是第二道保险即使锁没拦住条件更新也能保证不会把库存扣成负数。affectedRows为 0 时抛异常回滚整个抽奖作废。注意FOR UPDATE必须在事务里用否则锁会立即释放。参数方面activityId和code都要从可信来源传入不能由前端直接指定奖品 ID。3.3 抽奖码状态和中奖结果的最终一致性前面校验接口把码标记为已使用抽奖接口又更新了prize_id。这两个操作如果不在一个事务里可能出现码被标记使用但没抽到奖品的情况。常见做法是把校验和抽奖合并成一个接口用户点击抽奖时一次性完成“校验码 抽奖 扣库存 写结果”。如果业务上必须分开比如先校验再让用户确认那就要在抽奖接口里再次检查码的状态并且用UPDATE ... WHERE status 1 AND prize_id IS NULL来保证只处理已校验但未抽奖的码。-- 抽奖接口里再次确认码状态 UPDATE lottery_code SET prize_id ? WHERE code ? AND status 1 AND prize_id IS NULL;这条语句的status 1表示码已校验prize_id IS NULL表示还没抽过。两个条件同时满足才更新affectedRows为 1 才继续发奖。这样即使前端重复提交也只会成功一次。4. 九宫格动画与前后端联调让转盘停在该停的地方4.1 用 CSS 动画还是 JS 定时器控制高亮九宫格转动的视觉效果常见有两种实现一是用 CSSanimation配合steps()做逐格跳动二是用 JS 定时器每隔一段时间切换高亮格子的 class。CSS 方案性能好但不好控制“转到指定格子停住”和“先快后慢”的节奏。我一般用 JS 定时器因为抽奖结果是从后端返回的需要动态决定停在哪个格子。// 九宫格抽奖动画从startIndex开始转到targetIndex停住 function startLottery(targetIndex, callback) { const totalSteps 8 * 3 targetIndex; // 至少转3圈再停在目标格 let currentStep 0; let speed 50; // 初始间隔50ms const gridItems document.querySelectorAll(.grid-item); function step() { // 清除所有高亮 gridItems.forEach(item item.classList.remove(active)); // 高亮当前格子 const index currentStep % 8; gridItems[index].classList.add(active); currentStep; if (currentStep totalSteps) { // 动画结束停在targetIndex gridItems[targetIndex].classList.add(active); callback callback(); return; } // 最后10步减速 if (totalSteps - currentStep 10) { speed 30; } setTimeout(step, speed); } step(); }totalSteps计算为8 * 3 targetIndex意思是至少转 3 整圈再转到目标下标。currentStep % 8得到当前高亮的格子下标。speed初始 50ms最后 10 步每次加 30ms形成减速效果。callback在动画结束后调用用来弹出中奖结果。注意gridItems的顺序必须和前面prizeGrid数组的顺序一致否则会停错格子。参数targetIndex由后端返回的奖品 ID 反查得到。4.2 前后端联调时最容易对不上的三个字段联调阶段最耗时的不是逻辑而是字段对不上。我踩过的坑里排前三的是奖品 ID 类型不一致后端返回数字前端当字符串比较、九宫格下标从 0 还是 1 开始、以及“谢谢参与”是否占用独立 ID。解决方法是定一个接口契约用表格写清楚每个字段的类型和含义前后端都按这个来。字段名类型含义示例codestring抽奖码ABC123prizeIdnumber奖品 ID谢谢参与也有独立 ID103prizeIndexnumber九宫格下标0~72prizeNamestring奖品名称谢谢参与isWinboolean是否中奖false这张表里prizeIndex是后端算好返回的前端直接用它做动画目标不要在前端用prizeId去数组里找下标——因为数组顺序可能变。isWin用来决定弹窗文案中奖和谢谢参与走不同 UI。注意prizeId和prizeIndex都要返回前者用于记录后者用于动画。4.3 抽奖码输入框的防抖和格式校验用户输入抽奖码时常见问题是连续点击“抽奖”按钮导致重复请求。前端要做两件事一是按钮点击后立即禁用等接口返回再恢复二是输入框做格式校验比如长度 6 到 12 位、只允许字母数字。下面是一个简单的校验和防抖实现。let isDrawing false; async function handleDraw() { if (isDrawing) return; const code document.getElementById(codeInput).value.trim(); // 格式校验6~12位字母数字 if (!/^[A-Za-z0-9]{6,12}$/.test(code)) { alert(抽奖码格式不正确); return; } isDrawing true; document.getElementById(drawBtn).disabled true; try { const res await fetch(/api/draw, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ code }) }); const data await res.json(); if (data.ok) { startLottery(data.prizeIndex, () { alert(data.isWin ? 恭喜获得${data.prizeName} : 谢谢参与); }); } else { alert(data.msg); } } finally { isDrawing false; document.getElementById(drawBtn).disabled false; } }isDrawing标志位防止重复点击disabled属性给用户视觉反馈。正则/^[A-Za-z0-9]{6,12}$/限制码的格式避免用户输入空格或特殊字符。fetch请求里Content-Type必须设对否则后端可能解析不到 body。finally里恢复按钮状态保证即使请求失败也能再次点击。注意alert只是示例实际项目里应该用弹窗组件。5. 避坑与排查抽奖系统上线前必须过的五道坎5.1 坑一抽奖码被并发请求重复使用现象同一个码在两个浏览器同时提交两边都提示中奖。原因校验接口用了“先查后改”两个请求都查到 status0然后都改成 1。解决把校验和占用合并成一条UPDATE ... WHERE status 0用affectedRows判断是否成功。前面 2.3 节的代码就是正确写法。如果业务要求先校验再抽奖那抽奖接口必须再检查一次prize_id IS NULL。5.2 坑二库存扣成负数现象活动结束后盘点发现某个奖品发出数量超过库存。原因扣库存时没有加stock 0条件或者抽奖和扣库存不在一个事务里。解决扣库存语句必须写成UPDATE prize SET stock stock - 1 WHERE id ? AND stock 0并且和抽奖在同一个事务。如果affectedRows为 0说明库存不足要回滚并返回“谢谢参与”或“奖品已发完”。5.3 坑三九宫格动画停错格子现象后端返回中了一等奖但转盘停在了“谢谢参与”上。原因前端用prizeId去数组里找下标但数组顺序和后端不一致或者“谢谢参与”有多个导致indexOf找到第一个就返回。解决后端直接返回prizeIndex前端用它做动画目标。数组顺序在前后端用同一份配置不要各自维护。5.4 坑四抽奖码格式校验太宽松导致无效请求现象用户输入“abc”也能点抽奖后端返回“码无效”体验差。原因前端没做格式校验或者校验规则和后端不一致。解决前后端用同一套正则前端先拦一道后端再拦一道。常见规则是 6 到 12 位字母数字具体看发码规则。注意不要在前端暴露“码是否存在”的信息统一提示“无效或已使用”。5.5 坑五活动结束后还能抽奖现象活动截止时间已过用户仍能提交抽奖码并中奖。原因校验接口只检查了码状态没检查活动时间。解决在lottery_code表里加expire_at字段或者在活动表里加start_time和end_time校验时一并检查。SQL 里加AND NOW() BETWEEN start_time AND end_time。注意时间要用数据库时间不要用服务器本地时间避免时区问题。6. 进阶把抽奖码换成二维码以及压测时的一个小技巧前面讲的都是手动输入抽奖码。实际活动里更常见的是扫码抽奖——把抽奖码生成二维码用户扫码后自动填入。实现上不需要改抽奖逻辑只需要在发码时多生成一张二维码图片内容就是码本身。生成二维码可以用qrcode这个库几行代码就能搞定。const QRCode require(qrcode); // 生成二维码 Data URL前端直接放到 img 的 src 里 async function generateQR(code) { const url await QRCode.toDataURL(code, { width: 300, margin: 2, color: { dark: #000000, light: #ffffff } }); return url; }width是图片宽度margin是白边宽度color可以改二维码颜色。生成的 Data URL 是一串 base64直接给img标签用。注意二维码内容就是抽奖码本身不要加额外前缀否则扫码后还要解析。如果码比较长二维码密度会高width要相应调大。另一个进阶技巧是压测。抽奖系统上线前一定要压测重点看两个指标一是抽奖接口的 QPS二是库存扣减的准确性。我一般用autocannon或wrk对抽奖接口发压同时开一个脚本轮询数据库检查库存有没有变成负数、有没有同一个码被抽两次。压测时把抽奖概率调成必中这样能快速消耗库存暴露并发问题。压测完记得把概率调回去。最后说一个我自己的习惯每次上线抽奖活动前我会手动造 100 个测试码用脚本模拟 100 个并发请求同时抽然后检查数据库里这 100 个码的状态和奖品库存。这个动作花不了十分钟但能拦住大部分并发问题。抽奖系统最怕的不是抽不中而是抽中了发不出去或者发多了。希望帮到你。本文还有配套的精品资源点击获取
返回列表