简介一款基于PHP开发的九宫格抽奖码抽奖系统面向Web开发者、毕业设计学生及活动运营者用于生成和管理抽奖码并通过随机算法公平抽取获奖者可部署于商业活动、会议展览或课程设计案例。压缩包共699个文件大小约9MB除PHP业务逻辑外包含大量GIF/PNG图片素材、JS/CSS前端文件及SQL数据库脚本覆盖界面展示、交互样式与数据初始化等环节。目前已获得130人学习下载适合作为Web应用开发的实践素材。源码中划分了核心逻辑、入口页面、数据库操作、后台管理和静态资源等模块便于深入理解完整的请求处理流程同时通过修改配置和SQL脚本可快速适配不同活动场景并学习PHPMySQL协作、随机数算法及前后端联调等关键技能。1. 幸运九宫格抽奖码抽奖系统 v1.1不只九宫格更是一套发码兑奖闭环幸运九宫格抽奖码抽奖系统 v1.1 这类项目说起来是个转盘抽奖做起来其实是“前端九宫格动画 后端概率库存 抽奖码生成核销”三件事的合集。九宫格只是用户看得见的那层皮真正的复杂度在抽奖码用户每次抽奖拿到一个唯一码后面兑奖、对账、防重复核销全都要靠它。很多团队一开始直接用第三方抽奖SDK等运营提出“中奖用户必须凭码到线下门店核销”“要能对账到每一笔奖品”时第三方就兜不住了。自研一套抽奖系统适合有拉新促活需求、需要线下兑奖闭环、又不想受平台规则限制的活动开发。下面按“奖池模型 → 九宫格动画 → 核销闭环 → 踩坑”的顺序拆开讲。2. 奖池模型与抽奖码生成先决定“谁中了”再给中奖者一个能核销的码2.1 九宫格的九格不是等概率的权重表与动态库存九宫格虽然看起来有九个格子但“转到哪个格子”不能靠纯随机。运营要的通常是“一等奖概率 0.5%谢谢参与 35%”所以后端第一步是维护一份奖品权重表。常见做法是用一份 JSON 或数据库表每个奖品至少包含 prize_id、name、weight、total、remain 五个字段。weight 是基础权重remain 是剩余库存。抽奖时把 remain 为 0 的奖品权重直接视为 0再从剩余奖品里按权重比例取一个。我一般用“根据剩余库存动态归一化”的方式先过滤出所有 remain 0 的奖品计算它们的总权重再生成一个落在 [0, 总权重) 的随机数看它落进哪个奖品区间。这样能避免“一等奖库存还剩 10 个但因为基础权重只有 0.1%实际永远不会被抽中”的假概率问题。另一个必做的是保底机制运营要求“抽满 20 次必得某个奖品”不能靠随机得在抽奖前先查用户的累计抽奖次数达到阈值就直接返回保底奖品不走权重随机。import random from typing import List, Optional def pick_prize(prizes: List[dict]) - Optional[dict]: 按权重抽奖剔除库存为 0 的奖品。返回中奖奖品无奖品可抽时返回 None。 valid [p for p in prizes if p[remain] 0] if not valid: return None total_weight sum(p[weight] for p in valid) rand_val random.uniform(0, total_weight) cursor 0.0 for p in valid: cursor p[weight] if rand_val cursor: return p return valid[-1]这里的 valid 过滤是关键库存为 0 的奖品不进入权重计算否则用户会一直抽到“已空的奖”。random.uniform(0, total_weight) 生成的随机数严格小于 total_weight所以一定会落在某个奖品区间不会出现“什么都抽不到”的返回。注意random 模块在这里只用于概率抽样不用于生成抽奖码——抽奖码涉及安全必须用密码学安全随机数见下一小节。运营还常问“未中奖要不要占权重”。答案是要。把“未中奖”作为一个普通奖品项权重拉到 50% 到 70%用户在转盘 UI 上能看到一个“谢谢参与”的格子连续抽几次没中也符合心理预期。如果不占权重系统设计上虽然没漏洞但用户感知会非常差活动口碑容易崩。2.2 抽奖码生成不可猜测、可校验、能对账抽奖码是这套系统的核心资产。用户中了什么不重要重要的是他拿什么来兑奖。抽奖码最低要求是“猜不到、撞不了、能验错”。顺序自增 ID 绝对不行明文用户 ID 加时间戳也不行——前者可枚举后者可预测。生成随机段要用密码学安全随机源Python 里是 secretsGo 里是 crypto/rand不能用 math/rand 或 random 这种伪随机。我常用的抽奖码结构是“前缀 12 位随机段 1 位校验位”共 14 位。字符集去掉 0O1Il 这类易混淆字符避免用户手输时看错。校验位用 SHA-256 哈希的第一个字节映射到字符集目的是在人工录入或扫码识别出错时提前发现而不是防伪。真正的防伪要靠核销接口的限流、状态判断和唯一索引来兜底。import secrets import hashlib CHARSET 23456789ABCDEFGHJKLMNPQRSTUVWXYZ def generate_lucky_code(prefix: str L) - str: 生成抽奖码前缀 12 位安全随机字符 1 位校验字符 rand_part .join(secrets.choice(CHARSET) for _ in range(12)) code f{prefix}{rand_part} check calc_check_char(code) return f{code}{check} def calc_check_char(code: str) - str: 用 SHA-256 首字节映射到字符集作为校验位 digest hashlib.sha256(code.encode()).digest()[0] return CHARSET[digest % len(CHARSET)]secrets.choice 从系统熵源取随机数生成的随机段不像 random 模块那样有可预测的周期。校验位算法不加入随机段本身而是对“前缀 随机段”整体做哈希这样用户抄错任意一位服务器重算校验位就能发现。这个校验位只能验错防不了伪造——算法公开后别人也能算所以后续核销接口必须配合状态字段和限流。抽奖码生成后要落库表里至少要包含 code、prize_id、user_id、status、expire_at、created_at 六个字段code 字段必须建唯一索引数据库层面兜底碰撞如果插入时撞了唯一索引概率极低重试一次即可。3. 前端九宫格转起来把后端结果“演”成一次自然抽奖3.1 格子编号与布局先定方向再定目标索引九宫格布局上中心是抽奖按钮周围 8 个格子按顺时针从正上方开始编号 0 到 7。这个编号是前端、后端、测试之间的约定必须一致否则会出现“后端说中了索引 5前端却停在格子 6”的尴尬。方向我一般固定为顺时针因为顺时针最符合转盘心理预期逆时针偶尔会让用户觉得“倒着转”。前端用 CSS Grid 实现最省事核心是把 8 个格子按顺序放进 3×3 网格中心放按钮。HTML 结构里每个格子带上>style .wheel { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); gap: 4px; width: 360px; aspect-ratio: 1; } .cell { display: flex; align-items: center; justify-content: center; border: 1px solid #eee; } .center-btn { grid-column: 2; grid-row: 2; } /style div classwheel idwheel div classcell>function spinTo(index) { const totalSpins 5; // 转 5 圈视觉上更自然 const targetAngle totalSpins * 360 (360 - index * 45); wheel.style.transition transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99); wheel.style.transform rotate(${targetAngle}deg); }targetAngle 的计算里(360 - index * 45) 是在“多转一圈”的基础上回退到目标格子的位置。index0 时这个值是 360等于多转一圈回到原点index1 时是 315表示停在上方偏右 45 度的位置对应顺时针第 1 格。cubic-bezier 曲线让转盘先快后慢最后稳稳停住不会弹回。如果你希望索引 0 停在正上方而不是正右方基准角度要按实际布局方向调整公式本身是一样的。这里有一个必须养成的习惯先请求后端抽奖接口拿到中奖结果再驱动动画。把“转盘停下来那一刻”作为唯一随机点的方式绝对不能用——前端动画的随机结果不可控万一转盘停在一等奖而后端说没中奖你没法向用户解释。正确顺序是点击按钮 → 请求接口 → 拿到 prize_id 和 code → 调用 spinTo(prize_id) → 动画结束展示奖品和码。动画期间要加锁isRolling 为 true 时点击无效。不加锁的话用户连点会触发多个请求后端会返回多个抽奖码这就是后面避坑章节会详细讲的超发问题。4. 从发码到核销一次抽奖如何变成一次完整兑奖4.1 抽奖接口事务里扣库存生成抽奖码一个请求一个码后端抽奖接口要保证三件事原子完成扣减奖品库存、记录用户抽奖记录、生成并保存抽奖码。早期我用“先 select 再 update”的写法上线第二天库存就超卖了——两个并发请求都 select 到库存还剩 1然后都执行 update库存变成 -1。后来改成直接 update 带库存条件用 affected_rows 判断是否扣减成功。UPDATE prize SET remain remain - 1 WHERE prize_id ? AND remain 0;这条语句利用数据库行锁同一时刻只有一个事务能成功把 remain 从 1 改成 0另一个请求 affected_rows 为 0就说明该奖品没库存了。在事务里先执行这个 UPDATE成功后再 insert 抽奖记录和抽奖码失败则重新抽一次或返回“手慢了”。对于“谢谢参与”这种没有库存概念的奖品把 remain 设成一个大数值比如 999999保证它永远可中奖就行。“转一次出几个码”必须明确一个用户一次抽奖只发一个码。前端连点、后端重试、网络重发都会导致一个用户拿到多个码。所以抽奖接口要加幂等设计前端每次抽奖生成一个 requestId后端以 (user_id, request_id) 做唯一索引防重已经处理过的 requestId 直接返回第一次的结果。v1.1 版本如果没做这个至少要在业务代码里加一层分布式锁锁的 key 用 user_id防止同一用户并发请求。4.2 核销接口校验、防枚举、防重复兑奖核销是兑奖员用扫码枪或用户手输抽奖码完成的。流程是前端收集 code后端校验码格式正确、状态未核销、未过期然后原子地把它置为已核销。并发风险在于“同一个码被两家门店同时扫”。解决并发复用最简单的方式是数据库状态更新加条件跟扣库存一模一样更稳的是先抢 Redis 锁抢到锁才允许查库和更新。# 核销接口核心逻辑Redis DB def redeem(code: str, operator: str): # 1. 格式与校验位检查见 2.2 的 calc_check_char if not verify_code(code): return {code: 4001, msg: 码格式错误} # 2. 抢锁防止同一码并发核销 locked redis.set(fredeem:{code}, 1, nxTrue, ex10) if not locked: return {code: 4002, msg: 正在处理中请勿重复操作} try: row db.query(SELECT status FROM lottery_code WHERE code%s, code) if row is None: return {code: 4003, msg: 码不存在} if row[status] 1: return {code: 4004, msg: 该码已核销} if row[expire_at] now(): return {code: 4005, msg: 码已过期} # 3. 原子更新WHERE status0 是最后一道防线 db.execute( UPDATE lottery_code SET status1, redeemed_by%s, redeemed_atNOW() WHERE code%s AND status0, operator, code, ) db.commit() return {code: 0, msg: 兑奖成功} finally: redis.delete(fredeem:{code})第一步 verify_code 是本地校验位检查能挡住手误输错的码避免无效请求打到数据库Redis 锁的 nxTrue 配合 ex10 保证同一时刻只有一个核销请求能继续往下走UPDATE 语句里的 WHERE status0 是兜底即便 Redis 锁因为慢查询提前过期释放数据库也能保证同一个码只会被更新一次。锁的过期时间 10 秒对核销流程足够但要注意如果数据库查询超过 10 秒锁可能提前释放导致第二个请求进入所以 UPDATE 条件不能省略。核销接口还一定要加限流和失败次数限制。普通用户没有码但可以无限试错误码来枚举有效码。如果校验失败连续 5 次对该 IP 或操作员账号封禁 10 分钟。14 位码空间含字母理论枚举成本很高但有了这个限制更稳。另一个细节是抽奖码不要出现在日志里服务器日志只记录码的哈希避免日志泄露后被批量兑走。5. 抽奖系统避坑实录5 个上线后会咬人的细节5.1 活动刚开始半小时大奖被抽光了现象一等奖按配置概率 0.5% 发 50 个活动上线不到半小时后台显示一等奖库存为 0用户开始骂“黑幕”运营想临时加库存只能改配置重启活动口碑崩了。原因只设了总库存没设每日限量、每位用户限次也没有在抽奖前按剩余库存动态归一化权重。50 个头奖看着多在大流量下一瞬间就能被抽完。更深层的问题是把“基础概率”当成了“必然命中概率”没考虑流量放大效应。解决权重表里加 remain 和 limit_per_user 两个字段每次抽奖先检查用户当日累计抽奖次数超过上限直接拒绝库存不足的奖品过滤掉再算权重。同时中奖记录表要能实时统计剩余库存运营后台需要能看到“当前各奖品剩余多少”而不是等爆了才去查数据库。5.2 并发压测时库存变成负数现象压测工具模拟 200 个并发用户同时抽奖结束后数据库里一等奖库存变成了 -3用户端却还有人显示中奖。原因代码里写了“先 select 查库存再 update 扣减”两个请求同时读到库存为 1都执行 update就把库存扣成了负数。这是抽奖系统最常见的超卖问题本质是“检查 扣减”不是原子操作。解决把扣库存改成 UPDATE prize SET remain remain - 1 WHERE prize_id ? AND remain 0用 affected_rows 是否为 0 判断是否扣减成功成功才继续发码。分布式场景下用 Redis Lua 脚本扣减脚本里同时做库存检查和扣减效果一样。上线前压测时除了看 QPS还要专门检查“库存负数”和“返回码分布”两个指标。5.3 测试同学用脚本遍历抽奖码兑走了几十个奖现象测试环境有个人写脚本批量请求核销接口用自增数字猜码居然成功兑走了几十个奖线下兑奖员完全没察觉。原因抽奖码用顺序自增 ID 或简单时间戳加随机数生成可预测核销接口又没有限流同一 IP 可以无限次尝试。解决码生成改用 secrets 或 crypto/rand 这类密码学安全随机源码长至少 12 位加校验位核销接口按 IP 和操作员账号限频连续失败 5 次拉黑 10 分钟。抽奖码不要出现在前端页面源码、URL 参数和日志里。用户只能看到自己的码服务器日志只记录码的哈希避免日志被拖库后批量兑奖。5.4 用户疯狂点击一次活动多发出 3000 个重复抽奖码现象运营在后台看到同一个用户十分钟内有几十条抽奖记录奖品被重复发放用户拿多个码来兑奖。原因前端没有锁定动画状态用户连点触发了多次请求后端又没有幂等设计每个请求都当成一次新抽奖。v1.1 里如果抽奖接口只靠前端 disabled 属性防连点F12 改一下就能绕过。解决前端加 isRolling 锁和按钮 disabled动画期间忽略点击后端接 requestId 幂等前端每次抽奖生成一个 requestId后端以 (user_id, request_id) 做唯一索引重复请求直接返回第一次结果。这样即使前端被绕过后端也不会多发码。5.5 线上中奖分布和配置概率明显不一致现象活动跑了一天后后台按奖品统计的实发比例和配置概率偏差超过 5%一等奖实际发出数量比预期高出一截。原因不是随机算法有问题而是奖品库存变了但测试环境没有重置另一种情况是“未中奖”没有占权重导致中奖率被实际抬高。比如配置里一等奖 0.5%、二等奖 1%剩下 98.5% 都是“未中奖”如果未中奖没进权重池实际所有奖品概率会被重新归一化中奖率一下子翻几十倍。解决用 10 万次本地回归测试校验输出分布与配置概率的偏差控制在 1% 以内再上线发布时把库存和权重表一起做版本管理避免测试库和线上配置错位。抽奖接口里加一个“总中奖率”的实时监控指标偏差超过阈值直接告警要比事后对账快得多。6. 把抽奖码升级成“防伪票据”HMAC 派生码与离线校验v1.1 的抽奖码是靠数据库唯一索引防伪的码本身没有签名。如果接下来要做高并发活动或者线下渠道批量发码可以给抽奖码加一层 HMAC 派生逻辑所有码由同一个密钥通过 HMAC-SHA256 生成兑奖时用密钥重算一次不查数据库就能判断码是不是系统签发的。import hmac import hashlib import base64 SECRET bplease-change-me def issue_code(user_id: str, seq: int) - str: 用用户 ID 和序号派生抽奖码同一个密钥可离线验签 msg f{user_id}:{seq}.encode() digest hmac.new(SECRET, msg, hashlib.sha256).digest() return base64.b32encode(digest)[:12].decode()优点是离线也能验证真伪只要知道 SECRET 和生成规则不查库就能挡住一大半伪造码。缺点是仍需要数据库记录已核销状态来处理“同一码用两次”所以实际使用中要“离线验签 在线查状态”两步都做。SECRET 要放配置中心不能进代码仓库轮换密钥时要把旧密钥也保留一段时间否则旧活动未核销的码会全部验签失败。另一个我吃过亏的方向是压测方案。当时只测了抽奖接口的 QPS没测核销接口在并发下的状态流转上线当天运营在后台看到同一码被核销了两次的告警打电话说账对不上。后来我把“返回码分布”“库存负数检测”“同一码重复核销检测”写进发布前的自动化用例里每次发版先跑一遍这组用例再放量。这个习惯从那以后一直保留。抽奖系统最容易翻车的地方从来不是动画炫不炫而是“发码和核销这条闭环能不能对得上账”。先把概率模型和抽奖码设计想清楚再回头调前端转盘整个项目会顺很多。希望帮到你。本文还有配套的精品资源点击获取