首页 / 资讯中心 / 文章详情

口红机源码全解析:从概率模型到防超卖的前后端实现

口红机源码全解析:从概率模型到防超卖的前后端实现 ★ FEATURED ARTICLE
简介这份H5口红机源码是一套面向移动端场景的互动游戏项目核心目标是把线下口红机玩法复刻到手机浏览器中适合具备一定编程基础的开发者用于学习或二次开发。压缩包约30.52MB内含安装配置说明文档、用于初始化游戏数据的SQL脚本以及完整源码目录其中安装配置文档覆盖服务器部署与数据库连接要点SQL脚本预置等级、用户、奖品等基础数据源码目录以HTML、CSS、JavaScript文件为主并包含图片、音频等静态资源可从Canvas绘图、游戏逻辑、动态样式和交互实现多个层面理解项目。目前已有369人学习/下载源码开放程度高可据此修改游戏规则、调整难度或扩展新玩法。对希望系统练习H5开发、前后端数据配合及项目部署的开发者这套资源既是可运行的工程样例也是完整的学习示范项目。1. 口红机源码到底在做什么一个被当成“小游戏”的概率系统“H5口红机”这个标题第一次出现在某公司运营的需求单上时看起来只是一个小游戏用户在手机页面上翻格子凑齐指定数量的口红图标就能兑换一支口红。等真正动手做才发现它根本不是一个前端动画项目而是一套“概率控制 库存防超卖 反作弊 多端适配”的营销系统。口红机源码的难点不在翻牌动画而在“怎么保证运营不赔钱、玩家不骂娘、服务器不被刷穿”。这篇文章从概率模型讲到前后端代码再到上线前最容易翻车的五个点给出一份可以直接照着改的落地路径。适合有前端基础、打算接外包或给运营搭活动页的开发者。2. 口红机的玩法内核与概率模型先把“不赔钱”算清楚2.1 玩法内核翻格子只是表演中不中奖早就注定常见的口红机玩法是页面上摆 5x6 共 30 个格子玩家点开格子每个格子后面是一个口红图标或者“谢谢参与”。玩家需要在有限的翻牌次数里凑齐 5 个口红图标才算中奖。表面上这是“运气好”实际上中不中奖在玩家点击“开始抽奖”那一刻后端就已经返回了结果前端后面的翻牌动画只是照着剧本表演。为什么必须这样设计如果让前端自己随机决定格子内容懂技术的人打开浏览器开发者工具改一个变量就能把所有格子变成口红运营的奖品库存会在一小时内被刷空。所以口红机的第一个技术原则是前端只负责表现后端只负责结果。前端拿到后端下发的result字段后再去决定当前这轮动画播到哪一格停下来、凑齐几个口红。这不是某个源码包的特殊设计而是所有可上线作品的通行做法。后端返回的结果通常只有两三种中大奖凑齐 5 支口红、中小奖赠送优惠券或游戏币、未中奖翻出几个口红但凑不齐。前端根据结果去匹配动画分支比如未中奖时最多只能翻出 3 个口红中奖时第 5 个口红必定出现在玩家下一个点击的格子里。这样玩家会觉得“差一点就中了”但实际上结局早就写死。2.2 概率模型中奖率不是拍脑袋是拿预算倒推的设计概率前运营必须先给三个数单日预算、单支口红成本、单张优惠券成本。常见做法是我先让运营填一张这样的表项目数值示例说明单日总预算1200 元老板拍板的营销费用口红成本20 元 / 支采购含包装满减券成本3 元 / 张按历史核销率折算目标日参与人数5000 人预估活动流量单日中奖总成本不能超过预算否则活动做得越大亏得越多。用预算反推中奖概率假设每次抽奖要么中口红、要么中券、要么啥都没有口红中奖率为p1券中奖率为p2那么日均成本 ≈5000 × (p1 × 20 p2 × 3)。要让这个值小于 1200合理区间是p1 1.5%、p2 15%算下来日均成本约5000 × (0.015 × 20 0.15 × 3) 3750明显超了。这时就得调参把口红中奖率压到 0.5%券中奖率压到 8%成本变成5000 × (0.005 × 20 0.08 × 3) 1700仍然偏高。最稳妥的做法是每次抽奖前先查当前奖品剩余库存和今日已发成本库存不足时自动走“保底分支”只发券或只发谢谢参与。不要试图在前端写一套“看起来很随机”的算法后端的随机只依赖一张概率配置表。配置表里每一行是一个奖品档位字段包括pool奖池高价值/中价值/低价值、probability占总抽中的权重、stock剩余数量、daily_limit每日上限。抽奖时后端做一次带权随机先落档位再检查库存库存不够就降级到下一档。这个顺序很关键顺序反了会出现“抽中了但没库存”的事故。2.3 抽奖接口的最小字段设计够用且防重前后端约定一个轻量接口POST /api/v1/draw。请求体里必须带userId、gameId、requestId三个字段。requestId是前端生成的唯一流水号作用是幂等即使用户双击按钮、断网重试后端也只会处理一次。{ userId: u_12345, gameId: g_001, requestId: req_20250115_001, from: share }响应体如下{ code: 0, msg: ok, data: { result: win, prize: { id: p_02, name: 口红小样, type: physical }, scene: collect5, playId: pl_889 } }scene字段告诉前端用哪套动画模板playId是服务器记录该次抽奖的流水号前端埋点上报时要用。result只有win、lose、coupon三种取值不要搞更多花头否则前端每个分支都要写一套逻辑测试成本直线上升。参数说明from字段用来区分用户是从分享链接进来、还是从公众号菜单进来运营要统计渠道转化。接口必须做频率限制同一个userId每 10 秒最多请求一次每天最多参与 3 次超出直接返回code: 429。频率限制放在接口最前面比放在业务逻辑里更省资源。3. 从零搭建口红机前端翻牌动画、状态机与跨端适配3.1 卡片布局与 3D 翻转一套能跑的最小 HTML/CSS前端部分我从一个最小可跑通的布局讲起。30 个格子用 CSS Grid 排成 6 列每个格子是一张卡片卡片内部有两个面正面显示问号背面显示口红图标或文字。翻转动画用 CSS 的transform-style: preserve-3d和rotateY实现兼容性在 iOS Safari 和 Android Chrome 上都没问题关键是不要忘记写backface-visibility: hidden否则翻转后会看到背面内容透出来。div classboard idboard !-- JS 动态生成 .card -- /div.board { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; max-width: 420px; margin: 0 auto; padding: 12px; } .card { aspect-ratio: 1 / 1; perspective: 600px; cursor: pointer; } .card-inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.4s ease; } .card.flipped .card-inner { transform: rotateY(180deg); } .card-front, .card-back { position: absolute; inset: 0; backface-visibility: hidden; display: flex; align-items: center; justify-content: center; border-radius: 10px; font-size: 24px; } .card-front { background: #f5a3b7; } .card-back { background: #fff0f3; transform: rotateY(180deg); }这段代码有四个要点。第一.card必须设置perspective它是 3D 效果的视距不设置的话rotateY是平面压缩而不是立体翻转。第二.card-back本身要再转 180 度否则背面文字是镜像的。第三.card-inner的transition写在默认状态而不是.flipped状态这样来回翻转都有动画。第四aspect-ratio让格子自适应宽度避免在窄屏上被压扁。3.2 翻牌状态机防止用户连点刷出多个结果翻牌动画最忌讳的是用户快速连点导致多个格子同时翻转、动画互相覆盖、最后页面状态和服务器结果对不上。我一般在前端维护一个三段状态机idle空闲、checking正在判定、settling结算展示。只有在idle状态下才允许点击格子进入checking后立刻锁住所有点击。const state { phase: idle, // idle | checking | settling revealed: new Set(), lipstickCount: 0, roundResult: null }; function onCardClick(index) { if (state.phase ! idle) return; // 连点直接忽略 if (state.revealed.has(index)) return; // 已翻开不重复处理 state.phase checking; flipCard(index, lipstick); // 实际动画内容由后端结果决定 if (state.lipstickCount 1 5) { state.phase settling; showResult(state.roundResult); // 弹窗/跳转 } else { setTimeout(() { resetBoard(); // 本轮未中清空格子进入下一轮 state.phase idle; }, 600); } }逻辑说明flipCard只是播放动画真正的结果判断由roundResult控制。如果后端返回的是中奖结果前端会让玩家翻 5 个格子且第 5 个必然是口红如果未中奖则最多翻出 3 个口红然后提示“差一点”。resetBoard会清除所有.flipped类名并把lipstickCount归零视觉上就是重新铺了一组格子。参数说明setTimeout的 600 毫秒是给玩家看清楚最后一个格子的时间太短会让人觉得“没看清就结束了”太长影响节奏600 到 900 毫秒是常见区间。state.revealed用Set而不是数组因为格子数量固定Set的has查找是常数级别写起来也干净。3.3 音频、刘海屏和安卓 WebView三个必踩的适配点口红机为了营造开奖氛围都会加背景音乐和翻牌音效但移动端的音频规则和 PC 完全不同。iOS Safari 不允许页面加载时自动播放有声内容必须等用户第一次点击按钮后才能创建或恢复AudioContext。安卓 WebView 没有这么严格的限制但部分国产浏览器对AudioContext的实现有 bug直接play()会无声。let audioCtx null; function initAudio() { if (audioCtx) return; audioCtx new (window.AudioContext || window.webkitAudioContext)(); // 创建音效节点这里略去具体 buffer 加载 audioCtx.resume(); } document.getElementById(btn-start).addEventListener(click, () { initAudio(); playBgm(); });逻辑说明btn-start是开始抽奖按钮用户点击后才初始化音频既满足 iOS 的用户手势要求也避免页面一打开就响音乐被用户反感。audioCtx.resume()是 iOS 恢复挂起状态的关键调用不加这行已经创建的AudioContext会一直处于suspended状态。适配方面还有一个容易被忽略的点刘海屏和底部小黑条。口红包结果弹窗如果贴底显示会被 iPhone 的 home indicator 挡住按钮点不到。布局时给弹窗底部加上env(safe-area-inset-bottom)Android 端会自动忽略这个值不用做双端判断。安卓 WebView 翻牌动画偶发闪烁原因是 GPU 合成没生效解决办法是给.card-inner加上transform: translateZ(0)或will-change: transform强制开启硬件加速。4. 后端与数据表设计库存、幂等和防超卖4.1 三张核心表奖池配置、抽奖流水、用户额度后端需要三张基础表奖池配置表、抽奖流水表、用户每日额度表。很多新手只建一张“奖品表”就开写接口结果活动第三天就出现“库存为负”“同一用户中奖十次”的事故。三张表各司其职缺一不可。表名核心字段作用prize_configid, pool, name, type, cost, stock, daily_stock, weight配置每档奖品的库存和概率权重draw_logid, user_id, request_id, prize_id, result, created_at记录每一次抽奖流水用于对账和反作弊user_daily_limitid, user_id, game_date, draw_count限制每个用户每天参与次数设计上有几个细节。prize_config的pool字段区分高价值池和低价值池权重抽选时优先在高价值池查库存没库存自动滑落低价值池。draw_log的request_id必须加唯一索引这是幂等的基础。user_daily_limit用user_id game_date做联合唯一键防止并发插入导致同一个用户在同一天有两条额度记录。4.2 抽奖接口核心逻辑先随机、再锁库存、最后写流水接口伪代码用 Python 风格的表达代码里的注释说明了关键顺序def draw(user_id, game_id, request_id): # 1. 幂等检查request_id 已存在则直接返回原结果 if draw_log.exists(request_id): return draw_log.get(request_id).response # 2. 频率和额度检查 if not user_daily_limit.check(user_id, game_id): return error(429, daily limit) if not rate_limiter.check(user_id, draw, seconds10): return error(429, too frequent) # 3. 开启事务 with db.transaction(): # 4. 按权重抽选中奖档位 prize prize_selector.choose(game_id) # 5. 用条件更新锁库存失败则降级 ok db.update( UPDATE prize_config SET stock stock - 1 \ WHERE id %s AND stock 0, prize.id ) if not ok: prize prize_selector.fallback(game_id) # 低价值保底 db.update( UPDATE prize_config SET stock stock - 1 \ WHERE id %s AND stock 0, prize.id ) # 6. 写流水 draw_log.create(user_id, request_id, prize, result) user_daily_limit.increment(user_id, game_id) return build_response(prize)逻辑说明第一步幂等检查必须放在最前面。如果用户请求超时重试request_id相同后端不会再扣一次库存而是直接返回第一次的结果。这一步是防止“抽奖接口被刷”和“双花”的关键。第二步额度检查放在事务外因为它是高频只读操作放进事务会拉长锁的持有时间。参数说明prize_selector.choose做的是一次带权随机权重存在prize_config.weight字段里。比如口红权重 1券权重 10空奖权重 89那么中口红的概率就是 1%。fallback方法返回一个虚拟的“空奖项”它的prize_id可以是null这样draw_log里也能记录一次“未中奖”方便后续统计真实参与人数。4.3 防超卖和防重放条件更新 唯一索引超卖是营销活动最常见的线上事故——奖品显示还有库存但用户实际领不到。原因是先查库存再扣库存的操作不是原子的两个请求同时读到stock 1然后各自执行stock - 1最终库存变成-1。解决办法是 PostgreSQL 的UPDATE ... WHERE stock 0条件更新它保证“检查”和“扣减”在数据库层面是原子操作。UPDATE prize_config SET stock stock - 1 WHERE id :prize_id AND stock 0;执行后检查受影响行数rowcount 1说明扣减成功rowcount 0说明库存已经被抢完这时走fallback降级逻辑。不要用SELECT stock查出来再UPDATE那个方案在并发量上来后一定会翻车。防重放则是靠draw_log表上request_id的唯一索引第二次插入直接抛唯一约束异常事务回滚不会产生脏数据。还有一个库存上限问题prize_config.daily_stock表示今日可发数量和stock总库存分开。活动一般设置每日限量凌晨零点重置。重置逻辑用定时任务或WHERE created_at 今天的动态判断都行我倾向于定时任务每天凌晨把daily_stock重置为配置值简单直观。5. 上线前必查的 5 个翻车点现象、原因、解决5.1 并发把库存打成负数现象活动上线 10 分钟后台看到口红库存从 50 变成 -3。原因抽奖接口没有用条件更新而是“先查再扣”的非原子操作两个用户同时抽中最后一支口红两次扣减都成功了。解决统一改成UPDATE ... WHERE stock 0扣减后检查rowcount为 0 就走兜底奖品发放抽奖流水表补上request_id唯一索引防止同一个请求被重放。5.2 iOS 上翻牌音效不出声现象Android 手机一切正常iPhone 打开活动页面后点击格子没有任何音效但背景音乐正常。原因iOS Safari 要求音频必须在用户手势回调里创建和恢复AudioContext有些实现把initAudio放在DOMContentLoaded里执行被浏览器策略拦截了。解决把initAudio()绑定到“开始抽奖”按钮的click事件并且调用audioCtx.resume()如果用了独立的音效文件确保new Audio()也是在点击后再创建。5.3 安卓 WebView 翻牌动画闪烁残影现象某台安卓手机翻牌时卡片出现横向撕裂偶尔能看到下一张卡片的轮廓。原因部分低端安卓 WebView 的 GPU 合成没有覆盖transform动画页面在每一帧重绘时出现撕裂。解决给.card-inner加上transform: translateZ(0)强制合成层同时把backface-visibility: hidden写在.card-front, .card-back两面上不要只写一面。5.4 测试环境微信里打开活动页被拦截现象测试阶段用微信扫码打开链接页面直接提示“已停止访问”但浏览器里一切正常。原因链接域名没有备案或页面标题/文案触发了微信的诱导分享判定测试域名还容易被别的项目连累封禁。解决正式环境必须用已备案域名页面标题避免出现“免费”“抽奖”“口红”等强诱导词改成“品牌宠粉季”之类的中性文案测试时用浏览器的手机模拟模式不要老依赖微信扫码。5.5 优惠券发出去了但用户没用ROI 统计失真现象运营汇报活动效果时说中奖率 20%看起来效果很好对账时发现优惠券实际核销率只有 5%营销成本远超预期。原因把“发放优惠券”当成了“转化”没有区分发放数和核销数后端只记录了发券流水没有同步核销回调。解决优惠券系统里增加核销回调接口抽奖流水表和核销记录表每天跑一次对账统计 ROI 时用“核销成本”而不是“发放成本”给运营上报数据前先把coupon.used_at字段纳入统计口径。6. 让口红机更好用的三招数值仿真、渠道埋点、对账脚本6.1 发版前先跑一遍数值仿真别上线后靠拍脑袋调参很多团队上线后才发现中奖率定高了预算半天烧完运营手忙脚乱去后台改概率。我现在的习惯是发版前用脚本模拟一百万人次抽奖直接跑出预期的日成本、中奖人数和库存消耗确认没问题再上。import random def simulate(days30, daily_users5000, lipstick_stock100, rate0.005, coupon_cost3, lipstick_cost20): total_cost 0 lipstick_given 0 for _ in range(daily_users): r random.random() if r rate and lipstick_given lipstick_stock: lipstick_given 1 total_cost lipstick_cost elif r rate * 3: total_cost coupon_cost daily_cost total_cost print(f单日预估成本: {daily_cost} 元) print(f口红消耗: {lipstick_given} 支) return daily_cost simulate()逻辑说明把抽奖逻辑抽象成纯函数rate是口红中奖率lipstick_stock是当日可用库存循环一万次取平均值。参数说明这个模型里券的中奖率固定为口红中奖率的 3 倍真实项目应从prize_config.weight读取lipstick_stock参数很关键模拟它主要观察“库存到底够不够撑完一天”。模拟输出后和运营对一遍数字如果单日成本超预算把rate往下调如果口红库存晚上八点就发完说明中奖率太高也可以考虑把奖品改成“口红小样”降低成本。这一步花不了 10 分钟但能避免上线后改概率引发玩家投诉。6.2 渠道埋点是口红机的第二双眼睛活动页至少要埋四个事件page_view页面曝光、draw_click点击抽奖、draw_win抽中奖品、share_click点击分享。每个事件都带上from渠道参数这样运营能看到哪个渠道带来的用户抽奖意愿最高。埋点的代码很简单就是在翻牌动画的对应时机上报比如draw_win要在showResult之前上报不要在弹窗关闭后才上报否则用户杀掉进程会丢数据。6.3 对账脚本每天凌晨核对三张表的数字上线第二天开始每天要核对draw_log里的中奖数、user_daily_limit里的参与数和prize_config里的库存变化。写一个 cron 每小时跑一遍发现某张表的数字对不上就告警。这个习惯帮我抓住过一次“某渠道刷量”的问题那个渠道的draw_click和draw_log数量相差五倍一查是用户从分享链接反复进入页面却不抽奖浪费了大量广告费用后来在接口里加了按设备指纹去重的逻辑才解决。那次之后我给自己定了条规矩先跑仿真再发版先锁库存再随机先对账再复盘。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站