简介:H5口红机源码是一套面向Web前端与全栈开发者的互动游戏实战项目,适用于具备HTML5、JavaScript及基础数据库操作能力的学习者,用于快速构建移动端适配的趣味抽奖类H5应用。资源包共含多个关键组件:含详细部署说明的Word文档(.docx)、初始化数据库结构与奖品数据的SQL脚本(.sql),以及完整开源的第五版源码主目录,涵盖HTML页面、CSS样式、JS逻辑控制及静态资源,支持二次开发与功能定制。压缩包为ZIP格式,大小30.52MB,结构清晰、模块职责明确,便于理解H5游戏渲染流程、用户交互机制与前后端协同逻辑。目前已有369人学习下载,开发者可直接部署运行,深入掌握Canvas绘图、事件驱动编程、本地存储应用及MySQL数据对接等核心技能,是实践H5游戏开发与提升工程化能力的优质学习样本。
1. H5口红机源码:一个能跑在微信里、3分钟拉起抽奖页、带库存扣减和中奖逻辑的轻量级互动系统
你有没有遇到过这种场景:市场同事下午三点发来需求——“今晚八点要上线一个口红抽奖H5,用户点一下转盘,抽中就发券,库存500份,要防刷,最好能看实时中奖数据”?以前我第一反应是翻出上个项目里的抽奖组件改样式,结果发现库存校验写死在前端、没做请求幂等、中奖名单导出还得手动拼SQL……而这次,我把「H5口红机源码」丢进本地服务,npm run dev启动后直接用微信扫码,转盘一转,库存-1、中奖弹窗弹出、后台管理页里新记录已落库——整个链路干净得像刚洗过的代码。它不是玩具Demo,而是某快消品牌618大促真实用过的轻量级互动底座:纯前端渲染转盘动画,后端只暴露4个REST接口(初始化、抽奖、查记录、管理页),MySQL+Redis双写保障库存一致性,所有业务逻辑可配置化。适合需要快速交付、不依赖复杂中台、又不愿在安全和体验上妥协的中小项目团队。
2. 技术栈与架构设计:Vue3 + Pinia + Express + MySQL + Redis 的极简闭环
这个项目没有用微服务、没上K8s、没接消息队列——它刻意控制技术纵深,把复杂度压在「单次请求内闭环」上。核心思路很朴素:用户点击抽奖 → 前端发请求 → 后端原子化处理(查库存、扣库存、生成中奖记录、返回结果)→ 前端根据状态码驱动UI。所有状态变更都通过Redis Lua脚本保证原子性,MySQL仅作最终持久化。这种设计牺牲了高并发下的吞吐极限,但换来的是调试成本直降、故障定位秒级、二次开发时不用猜“消息什么时候到”“补偿任务跑没跑”。
2.1 前端技术选型:Vue3组合式API + CSS3硬件加速转盘
项目前端基于Vue3.3+Vite4构建,未引入任何UI框架,所有组件手写。核心交互载体是<LipstickWheel>组件,其转盘动画不依赖第三方库,而是用CSS3transform: rotate()配合transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1)实现物理惯性效果。关键点在于:旋转角度由后端返回的prizeIndex决定,而非前端随机生成——这是防作弊的第一道门。
<!-- src/components/LipstickWheel.vue --> <template> <div class="wheel-container" @click="handleSpin"> <div class="wheel" :style="{ transform: `rotate(${currentRotate}deg)` }" :class="{ 'spinning': isSpinning }" > <!-- 8个奖品扇区,每个22.5度 --> <div v-for="(prize, i) in prizes" :key="i" class="sector" :style="getSectorStyle(i)"> <span>{{ prize.name }}</span> </div> </div> </div> </template> <script setup> import { ref, computed } from 'vue' import { useSpinStore } from '@/stores/spin' const spinStore = useSpinStore() const isSpinning = ref(false) const currentRotate = ref(0) // 后端返回 prizeIndex=3(从0开始),则需旋转:3*45 + 360*3 + 随机偏移(保证每次不同) const handleSpin = async () => { if (isSpinning.value || spinStore.isDisabled) return isSpinning.value = true try { const res = await spinStore.doSpin() // 调用Pinia action,实际发POST /api/spin if (res.code === 0) { const baseAngle = res.data.prizeIndex * 45 // 每个扇区45度 const extraTurns = 3 * 360 // 至少转3圈 const offset = Math.floor(Math.random() * 45) // 防止用户记忆角度 currentRotate.value = baseAngle + extraTurns + offset setTimeout(() => { isSpinning.value = false spinStore.showResult(res.data) }, 3500) // 与CSS transition-duration一致 } } catch (e) { isSpinning.value = false alert('抽奖失败,请重试') } } </script>提示:
currentRotate不直接设为最终角度,而是靠CSStransition自动插值。这样既避免requestAnimationFrame手动控制的复杂度,又确保动画丝滑——实测iOS Safari下帧率稳定在58fps以上。
2.2 后端核心流程:Express中间件链 + Redis Lua原子扣减
后端用Express 4.18搭建,仅暴露4个路由,无任何ORM,SQL手写。最关键的/api/spin接口,其处理链如下:rateLimitMiddleware(IP限频) →authMiddleware(校验微信OpenID签名) →inventoryCheck(Lua脚本查扣库存) →recordPrize(写MySQL+Redis缓存) →sendResult(返回结构化JSON)
其中库存扣减是整个系统的命脉,采用Redis Lua脚本实现原子操作:
-- redis-scripts/inventory_deduct.lua -- KEYS[1]: 库存key, ARGV[1]: 扣减数量, ARGV[2]: 用户唯一标识(用于防刷) local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock < tonumber(ARGV[1]) then return {0, "库存不足"} -- 返回数组:code, msg end -- 检查该用户今日是否已抽过(防刷) local userKey = 'user:' .. ARGV[2] .. ':spin_today' if redis.call('EXISTS', userKey) == 1 then return {1, "今日已抽过"} end -- 原子扣减 + 设置用户标记 redis.call('DECRBY', KEYS[1], ARGV[1]) redis.call('SET', userKey, '1') redis.call('EXPIRE', userKey, 86400) -- 24小时过期 return {2, "success", stock - tonumber(ARGV[1])}Node.js调用该脚本的代码段:
// server/middleware/inventoryCheck.js const redisClient = require('../config/redis') const inventoryScript = require('../redis-scripts/inventory_deduct') async function inventoryCheck(req, res, next) { const { openid } = req.user // 由authMiddleware注入 const stockKey = 'stock:lipstick:2024' // 实际项目中可动态生成 try { const result = await redisClient.evalsha( inventoryScript.sha, // 预加载脚本SHA1 1, // KEYS数量 stockKey, 1, // 扣减数量 openid ) if (result[0] === 0) { return res.json({ code: 400, msg: result[1] }) } if (result[0] === 1) { return res.json({ code: 403, msg: result[1] }) } // result[0]===2,扣减成功,继续后续逻辑 req.deductedStock = result[2] // 供后续记录使用 next() } catch (err) { console.error('库存扣减异常:', err) res.status(500).json({ code: 500, msg: '系统繁忙' }) } } module.exports = inventoryCheck注意:Lua脚本里
userKey的过期时间设为86400秒(24小时),而非用INCR+EXPIRE分两步——后者在高并发下可能因网络延迟导致EXPIRE未执行,造成用户永久锁定。这是血泪经验换来的写法。
2.3 数据模型设计:三张表撑起全部业务
整个系统仅用3张MySQL表,无外键、无复杂关联,全部为B+树索引优化:
| 表名 | 字段说明 | 索引 |
|---|---|---|
prizes | id(PK),name(VARCHAR),type(ENUM: 'coupon'/'nothing'),value(TEXT, 存券码或空字符串),weight(TINYINT, 抽奖权重) | PRIMARY KEY(id),INDEX(type) |
spin_records | id(PK),openid(CHAR32),prize_id(INT),status(TINYINT: 0待发放/1已发放/2失效),created_at(DATETIME) | PRIMARY KEY(id),INDEX(openid),INDEX(prize_id, status) |
admin_config | key(VARCHAR),value(TEXT),updated_at(DATETIME) | PRIMARY KEY(key) |
prizes.weight字段是关键:后端抽奖算法不使用Math.random(),而是按权重累加后二分查找。例如8个奖品权重分别为[10,10,10,10,10,10,10,30],则中奖概率分布严格符合预期,且可随时后台修改权重实时生效。
3. 本地运行指南:5步启动,跳过所有玄学配置
很多H5项目卡在第一步——环境配不起来。这个源码包做了三件事:删掉所有全局依赖假设、内置最小化配置、提供一键检查脚本。你不需要装nvm、不用配MySQL密码策略、甚至不用改.env文件。
3.1 环境准备:确认基础工具链
请确保以下工具已安装(版本非强制,但建议):
- Node.js ≥ 16.14(
node -v验证) - npm ≥ 8.19(
npm -v验证) - MySQL 5.7+(
mysql --version验证,无需root权限) - Redis 6.2+(
redis-cli --version验证)
提示:若本地没装Redis,可用Docker快速启动:
docker run -d --name h5-redis -p 6379:6379 -d redis:6.2-alpine。MySQL同理,但注意修改后续配置中的host。
3.2 初始化数据库:执行SQL脚本自动建库建表
项目根目录下有init_db.sql,内容已预置数据库名h5_lipstick和用户权限语句。直接执行:
# 创建数据库(如已存在可跳过) mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS h5_lipstick DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" # 导入表结构和初始数据 mysql -u root -p h5_lipstick < init_db.sqlinit_db.sql关键内容节选:
-- 创建用户并授权(避免用root连接生产) CREATE USER 'h5_app'@'localhost' IDENTIFIED BY 'h5_secure_2024'; GRANT SELECT, INSERT, UPDATE ON h5_lipstick.* TO 'h5_app'@'localhost'; -- 插入默认奖品(口红券权重30,谢谢参与权重10) INSERT INTO prizes (name, type, value, weight) VALUES ('YSL圣罗兰小金条', 'coupon', 'YSL2024XXXXXX', 30), ('谢谢参与', 'nothing', '', 10), ('MAC子弹头', 'coupon', 'MAC2024XXXXXX', 10);3.3 启动前后端:两个终端,三行命令
终端1:启动后端
cd server npm install npm run dev # 监听 http://localhost:3000终端2:启动前端
cd client npm install npm run dev # 监听 http://localhost:5173,自动打开浏览器此时访问http://localhost:5173,页面会显示转盘。但注意:首次访问会提示“未配置微信JS-SDK”,这是正常现象——因为微信校验需要线上域名,本地开发时我们绕过它。
3.4 本地微信调试:用vConsole+Mock数据替代真机扫码
真机调试前,先用mock数据验证逻辑是否通:
- 打开
client/src/stores/spin.js - 找到
doSpin()action,注释掉真实API调用,改为:
// 注释掉这行:return api.post('/api/spin', { openid: this.openid }) // 替换为: return new Promise(resolve => { setTimeout(() => { resolve({ code: 0, data: { prizeIndex: Math.floor(Math.random() * 8), prizeName: 'YSL圣罗兰小金条' } }) }, 800) })- 刷新页面,点击转盘即可看到中奖弹窗——证明前端逻辑完整。
注意:mock模式下库存不会真实扣减,仅用于UI联调。切回真实API前务必还原代码。
3.5 线上部署关键配置:Nginx反向代理与HTTPS强制
当部署到云服务器时,必须配置Nginx将/api/*路径代理到后端,同时启用HTTPS(微信JS-SDK强制要求):
# /etc/nginx/conf.d/h5-lipstick.conf server { listen 443 ssl; server_name your-domain.com; ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; location /api/ { proxy_pass http://127.0.0.1:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { root /var/www/h5-lipstick/client/dist; try_files $uri $uri/ /index.html; } }提示:
proxy_pass末尾的/不能省略,否则会导致/api/spin被错误转发为http://127.0.0.1:3000/api/spin(多了一层/api)。这是新手翻车最高频的配置点。
4. 常见问题排查:5条真实踩坑记录,每条都带复现路径
项目跑起来后,90%的问题集中在环境适配和微信校验环节。以下是我在三个不同客户现场记录的真实问题,按「现象→原因→解决」结构整理,拒绝模糊描述。
4.1 现象:前端点击转盘无响应,控制台报错Failed to fetch
原因:后端服务未启动,或前端.env中VUE_APP_API_BASE指向错误地址(如仍为http://localhost:3000,但后端实际监听127.0.0.1:3000)
解决:
- 运行
lsof -i :3000确认后端进程是否存在 - 检查
client/.env文件,确保VUE_APP_API_BASE=http://your-server-ip:3000(开发环境可临时设为http://127.0.0.1:3000) - 若用Docker部署后端,确认容器端口映射正确:
docker run -p 3000:3000 ...
4.2 现象:中奖后MySQL中spin_records表无记录,但Redis库存已扣减
原因:server/config/db.js中数据库连接配置错误,host填成了localhost(Docker内网解析失败)
解决:
- Docker环境下,MySQL容器名若为
mysql-db,则host必须设为mysql-db(而非localhost或127.0.0.1) - 验证方法:进入Node.js容器执行
ping mysql-db,确认网络连通
4.3 现象:微信内打开白屏,控制台报错config: invalid signature
原因:server/utils/wechat.js中APP_ID或APP_SECRET填写错误,或jsapi_ticket缓存未更新
解决:
- 登录微信公众号后台,核对「开发->基本配置」中的AppID和AppSecret
- 删除Redis中
wechat:jsapi_ticket键:redis-cli DEL "wechat:jsapi_ticket" - 重启后端服务,首次请求会自动拉取新ticket
4.4 现象:多人同时抽奖时出现超发,库存扣成负数
原因:Redis Lua脚本未正确加载,evalsha执行失败后降级为eval,但eval在集群模式下不支持
解决:
- 检查
server/redis-scripts/index.js中loadScript函数是否抛错 - 在Redis CLI中手动执行
SCRIPT LOAD验证脚本SHA:redis-cli SCRIPT LOAD "$(cat server/redis-scripts/inventory_deduct.lua)" - 若返回
nil,说明Lua脚本语法错误(常见于中文标点、缩进混用)
4.5 现象:管理页/admin登录后空白,Network显示401 Unauthorized
原因:admin_config表中key='admin_password'的value字段为空或格式错误(应为bcrypt加密后的密文)
解决:
- 用bcrypt在线工具(如https://bcrypt-generator.com)生成密码密文
- 执行SQL更新:
INSERT INTO admin_config (`key`, `value`, `updated_at`) VALUES ('admin_password', '$2b$10$xxxx...', NOW()) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), `updated_at` = VALUES(`updated_at`);
5. 二次开发实战:3个高频改造场景及代码级实现
拿到源码后,业务方提的需求往往很具体:“把口红换成香水”“增加分享得额外抽奖次数”“中奖后跳转到小程序”。这些都不是改几个字的事,而是要理解数据流如何贯穿前后端。下面以三个真实需求为例,给出可直接抄的代码和验证方法。
5.1 场景一:替换奖品池,支持动态配置奖品图片与文案
原始奖品信息硬编码在prizes表中,但运营需要随时更换主视觉图。改造思路:在prizes表新增image_url字段,并让前端<LipstickWheel>组件根据prizeId异步加载对应图片。
步骤1:扩展数据库字段
ALTER TABLE prizes ADD COLUMN image_url VARCHAR(255) DEFAULT '' AFTER value; UPDATE prizes SET image_url = 'https://cdn.example.com/prize_yves.png' WHERE name = 'YSL圣罗兰小金条';步骤2:修改前端奖品渲染逻辑
<!-- src/components/LipstickWheel.vue --> <div v-for="(prize, i) in prizes" :key="i" class="sector" :style="getSectorStyle(i)"> <img :src="prize.image_url" :alt="prize.name" class="prize-img"> <span>{{ prize.name }}</span> </div>步骤3:增强后端API返回字段
修改server/routes/spin.js中/api/init接口,在查询prizes时带上image_url:
// 原SQL:SELECT id,name,type,value,weight FROM prizes // 改为: SELECT id,name,type,value,weight,image_url FROM prizes ORDER BY weight DESC验证方法:在管理页修改某奖品的
image_url,刷新H5页面,观察转盘扇区图片是否实时更新。注意CDN缓存问题,可加时间戳参数::src="prize.image_url + '?t=' + Date.now()"
5.2 场景二:增加“邀请好友得抽奖机会”功能
需求本质是:用户A分享链接给B,B首次访问时自动绑定关系,A获得1次额外抽奖机会。关键点在于关系绑定必须幂等,且不能影响主抽奖流程。
实现方案:用Redis Set记录邀请关系,独立于库存系统
- 新增接口
/api/bind-invite,接收referrer_openid(邀请人)和openid(被邀请人) - Lua脚本确保一个用户只能被绑定一次:
-- redis-scripts/bind_invite.lua local key = 'invite:ref:' .. KEYS[1] if redis.call('SISMEMBER', key, KEYS[2]) == 1 then return 0 -- 已存在 end redis.call('SADD', key, KEYS[2]) redis.call('INCR', 'extra_spin:' .. KEYS[1]) -- 为邀请人增加1次机会 return 1- 前端在
mounted钩子中检查URL参数?ref=xxx,存在则调用该接口 - 修改
/api/spin逻辑:查询extra_spin:{openid}剩余次数,大于0则扣减并允许抽奖
血泪经验:不要在
/api/spin里直接查邀请关系!否则每次抽奖都要穿透Redis,拖慢核心链路。必须把“邀请奖励”作为独立资源预计算。
5.3 场景三:中奖后跳转至小程序,携带中奖凭证
微信要求跳转小程序必须用wx.navigateToMiniProgram,且需appId、path、extraData三要素。难点在于extraData需包含加密的中奖信息,防止被篡改。
安全实现:用AES-CBC加密中奖ID,密钥存于环境变量
// server/utils/encrypt.js const crypto = require('crypto') const algorithm = 'aes-256-cbc' const key = Buffer.from(process.env.AES_KEY, 'hex') // 32字节 const iv = Buffer.from(process.env.AES_IV, 'hex') // 16字节 function encrypt(text) { const cipher = crypto.createCipheriv(algorithm, key, iv) let encrypted = cipher.update(text, 'utf8', 'hex') encrypted += cipher.final('hex') return encrypted } // 在 /api/spin 接口返回结果中加入: res.json({ code: 0, data: { prizeIndex: 0, prizeName: 'YSL圣罗兰小金条', miniProgramData: encrypt(`${recordId}_${Date.now()}`) // recordId来自spin_records.id } })前端调用:
// client/src/stores/spin.js wx.navigateToMiniProgram({ appId: 'wx1234567890abcdef', path: 'pages/redeem/index?data=' + res.data.miniProgramData, success: () => { /* 跳转成功 */ } })从那以后我每次加小程序跳转需求,都强制走一遍「加密→解密→验签」三步验证:用Postman调用加密接口,再用Node.js脚本解密,最后比对明文是否匹配。哪怕多花2分钟,也比上线后用户投诉“券码无效”强。希望帮到你
本文还有配套的精品资源,点击获取