H5刮刮乐抽奖全流程开发:canvas刮奖、免公众号分发与多级分佣实践

发布时间:2026/9/16 6:17:48
H5刮刮乐抽奖全流程开发:canvas刮奖、免公众号分发与多级分佣实践 简介一份基于ThinkPHP开发的H5幸运刮刮乐抽奖系统源码面向运营公众号或需要快速上线抽奖活动的开发者和站长免公众号也可直接运营并支持多级分佣推广裂变。源码包共2013个文件压缩包大小约42.01MB主要包含PHP后端逻辑、JavaScript交互脚本、JSON配置数据、HTML模板及大量PNG/GIF图片素材php文件承担接口与业务处理js实现刮奖效果json用于参数配置图片覆盖奖品与活动界面整体结构清晰便于二次开发。目前已有108人学习下载。资源内附详细搭建教程涵盖MySQL5.6、PHP7.2环境配置、Swoole/Redis扩展安装、站点设置、数据库导入及关键配置文件修改后台地址与默认账号密码已提供管理员密码可在权限管理中修改支付接口与多级分佣规则均可后台调整适合营销抽奖、粉丝互动或分销推广场景。1. 从zip到上线H5刮刮乐抽奖要解决的四件事这个标题看着像一份压缩包资源实际指向的是私域运营领域常见的H5刮刮乐抽奖项目用户打开一个H5页面在手机上用手指刮开涂层随机拿到优惠券、红包或实物奖品运营方再通过推广链接把用户串成上下级关系链按层级给邀请人分润。真正从零搭这套东西时被卡住的往往不是刮奖动画而是四个没人提前讲透的地方H5在微信里怎么做到免公众号也能直接打开、canvas刮涂层的坐标为什么总偏移、多级分佣的分润比例在数据库里该怎么设计才能不高并发出错、zip包的源码部署到服务器后要怎么防止被反复抓包和二次打包。这篇文章按一条完整的主线往下讲从前端刮奖组件写起到H5分发部署再多级分佣后端落库最后收在安全加固和运营验证上。无论你是前端、后端还是运营负责人照着这条线能独立把一个免公众号的刮刮乐抽奖H5从静态页面推进到可运营状态。2. canvas刮奖核心实现与h5多端适配2.1 刮刮乐的核心是canvas合成与擦除不是图片切换刮刮乐玩法最常见的一个错误实现是用多张PNG图片叠加手指滑过的地方换成透明图片。这种做法图片体积大而且边缘过渡生硬在低端安卓机上滑动时明显掉帧。正确做法是只用一个canvas画布盖在中奖结果上方canvas先绘制一层灰色涂层手指经过的位置用globalCompositeOperation destination-out把像素擦掉露出下面真正的奖品图。destination-out的语义是目标像素减去源像素正好对应真实世界中指甲刮开涂层的物理过程。const canvas document.getElementById(scratch); const ctx canvas.getContext(2d); let isDrawing false; // 先铺一层涂层 ctx.fillStyle #C0C0C0; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.font bold 28px sans-serif; ctx.fillStyle #999; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(刮一刮, canvas.width / 2, canvas.height / 2); canvas.addEventListener(touchstart, startScratch, { passive: true }); canvas.addEventListener(touchmove, scratch, { passive: true }); canvas.addEventListener(touchend, stopScratch); function startScratch(e) { isDrawing true; scratchAt(e); } function scratch(e) { if (!isDrawing) return; const rect canvas.getBoundingClientRect(); const x (e.touches[0].clientX - rect.left) * (canvas.width / rect.width); const y (e.touches[0].clientY - rect.top) * (canvas.height / rect.height); ctx.globalCompositeOperation destination-out; ctx.beginPath(); ctx.arc(x, y, 14, 0, Math.PI * 2); ctx.fill(); } function stopScratch() { isDrawing false; checkProgress(); }这段代码里最容易出问题的是坐标换算。getBoundingClientRect()拿到的宽度是CSS渲染宽度而canvas内部有自己的像素宽度canvas.width两者在移动端几乎不会相等因为页面CSS经常设置width: 100vwcanvas内部width属性却是固定的750。不乘canvas.width / rect.width这个缩放比手指在屏幕上滑动的位置映射到画布内部会整体偏移用户刮左边结果右边露出来。arc的第三个参数14是擦除半径12到18之间算合理区间小于12感觉刮不动大于18容易一蹭就露底。这里还要提一个防呆点touchstart只记录一次状态touchmove里用e.touches[0]取第一个触点保证用户即使第二根手指误触屏幕也不会导致刮除坐标跳动。2.1.1 刮开比例的算法与触发阈值刮奖不能等用户把每个像素都刮干净才结算这不现实。常见做法是在touchend事件后用ctx.getImageData扫描一遍涂层区域的Alpha通道统计透明像素占总像素的比例超过阈值就播放完全刮开动画露出结果并自动归位。function checkProgress() { const imageData ctx.getImageData(0, 0, canvas.width, canvas.height).data; let cleared 0; const totalPixels canvas.width * canvas.height; for (let i 3; i imageData.length; i 4) { if (imageData[i] 0) { cleared; } } const percent cleared / totalPixels; if (percent 0.6) { // 刮开达到60% ctx.clearRect(0, 0, canvas.width, canvas.height); revealResult(canvas.dataset.prizeId); } }getImageData返回的数组每四个元素为一组像素分别是R、G、B、A其中A是Alpha通道。destination-out擦除后的像素Alpha会变成0所以直接判断A为0就算被刮开。阈值设在0.55到0.7之间60%是大众接受度较高的值因为如果没刮净涂层就弹出结果用户会觉得我还没刮完凭什么告诉我是什么奖品阈值太高涂层剩下几条顽固边角时又要反复补刮。性能上要注意不要在touchmove每次触发时都做全图遍历几万像素的遍历在低端环境下会卡UI正确做法是用lastCheckTime做节流距离上次检查不足300ms就跳过。2.2 用uniapp做一套代码跑h5、小程序和App端标题里H5和app同时出现行业里最常用的方案是用uniapp编写一套Vue代码同时编译成H5和小程序再通过HBuilderX或云打包出iOS和Android安装包。选择uniapp而不是纯原生是因为刮刮乐这个玩法在WebView和小程序里都以canvas为核心并不需要调用底层的原生图形API跨端框架足够胜任。在uniapp工程里刮刮乐建议单独抽成一个组件ScratchCard.vue页面引入后传入奖品格子和奖品名称。编译到H5端时可以直接用HTML5 canvas编译到小程序端则要用小程序的canvas 2d接口但uniapp内部会做一层桥接这也是它省事的地方。# 运行到浏览器H5 npm install npm run dev:h5 # 构建生产H5包 npm run build:h5manifest.json里重点检查H5模块的router.base配置。在微信公众号或企业微信里打开H5路径通常被挂载在某个业务域名下比如https://运营域名.com/h5/scratch/这时base要设为/h5/scratch/。如果漏配或配成绝对路径部署后资源加载会404页面白屏这是搭建H5环境最常见的错误。App端要额外注意是token的存储位置H5端可以放localStorageApp端则建议放uni.setStorageSync后者会落盘到原生存储并在App被杀后仍保留。做免公众号运营H5和App共用同一套后端接口userId体系要对齐避免用户从H5跳转到App后分佣关系断链。2.2.1 不同端canvas适配参数对照运行端canvas获取方式坐标事件缩放比计算常见坑H5浏览器getContext(2d)touchstart/move用getBoundingClientRect需要做passive事件标记微信内置浏览器getContext(2d)touch事件同上底部工具栏弹起导致视口变化微信小程序createSelectorQuery().select(#canvas)bindtouchstartuni.createSelectorQuery的boundingClientRect同步返回canvas需设type2dApp端WebViewgetContext(2d)touch事件同上刘海屏需要适配安全区小程序端的canvas坐标获取方式和H5不同uniapp里建议统一用uni.createSelectorQuery().in(this).select(.scratch-canvas).boundingClientRect()拿节点信息返回值里的width和height会同时包含CSS尺寸和实际像素尺寸再用canvas.width / rect.width计算缩放比例。App端WebView里要额外用uni.getSystemInfoSync()判断safeArea顶部高度避免自定义导航栏时涂层区域被刘海遮挡。3. 免公众号直运营的H5分发路径3.1 免公众号真正免掉的是哪三件事很多运营者看到免公众号三个字会以为可以完全脱离微信生态实际上并不是。真正表达的含义是不需要去微信公众平台申请开发者权限、不需要配置JS接口安全域名、不需要服务号认证。普通用户扫码或点击链接打开的H5页面本质就是一个公网可访问的网页用户不需要先关注公众号也不需要跳转授权页面进来就能玩。这比依赖公众号模板消息触达要直接得多也更适合那些没有企业主体或不想走认证流程的个人运营者。使用免公众号模式时要建立明确的UID体系替代openid。公众号授权方案里openid是用户在该公众号下的唯一标识免公众号方案里前端生成UUID或邀请码后端保存这个标识并绑定分佣关系。需要注意微信内置浏览器里打开普通链接本身不会过期但如果微信调整外链规则流量入口会失效所以H5页面底部通常预留下载App或复制口令的兜底入口保证老用户不流失。3.2 用nginx把H5和API服务绑定在同一域名下免公众号H5要上线运营最稳妥的结构是前端静态页面和后端API放在同一个主域名下避免跨域。nginx配置上做出区分前端路径返回静态文件API路径反代到后端服务。server { listen 443 ssl; server_name scratch.example.com; ssl_certificate /etc/nginx/ssl/example.crt; ssl_certificate_key /etc/nginx/ssl/example.key; root /usr/share/nginx/html/scratch; index index.html; location / { try_files $uri $uri/ /index.html; add_header Cache-Control no-store, no-cache, must-revalidate; } location /api/ { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }try_files $uri $uri/ /index.html这一行是SPA路由回退。H5页面内部如果用了history模式路由访问/share/invite/12345时服务器上并没有这个真实文件没有这行配置会直接404。cache-control设成no-store是防止index.html被缓存后用户刷新看到的还是未中奖的老页面但静态资源比如/static/js/chunk.js不要全局禁用缓存否则每个用户每次进入都重新下载完整资源。X-Forwarded-For是为后续分佣系统准备的后端要拿到用户真实IP判断设备归属如果开发时后端未启用则不需要强依赖。3.3 在微信里获取用户位置而不用公众号授权抽奖活动常常要引导用户到店领奖需要位置信息。热点检索里大量出现uniapp开发h5嵌入微信公众号中获取定位实际在免公众号模式下有两种实现路径。路径一是用navigator.geolocation这不需要任何公众号授权只需要用户在浏览器弹窗里点允许微信内置浏览器对geolocation的支持程度不稳定部分安卓机直接不回调。路径二是干脆不走定位用第三方IP地理库通过用户出口IP推断城市让用户确认。这种方案的体验是打开页面后弹出一个城市确认框比如检测到您在广州是否切换门店用户点确认即完成。实现方式是后端读X-Forwarded-For里的IP调一个IP归属地接口拼接城市。async function fetchCity() { const ipResp await fetch(/api/v1/visitor/ip); const { city } await ipResp.json(); const confirmed confirm(检测到您可能在${city}前往最近门店领取奖品); if (!confirmed) return; loadStores(city); }接口/api/v1/visitor/ip由后端实现代理里已经收到真实IP后端把它转发给运营者自己买的ip归属库比在前端直接调第三方sdk更不容易暴露第三方数据源。这个方式也避开了微信JS-SDK必须绑定公众号安全域名的问题整体链路和微信的权限体系解耦。4. 多级分佣系统的分润表设计与异步结算4.1 用户关系链只存inviter_id不存完整路径多级分佣表面上是一个我邀请你、你邀请他的关系网但落地到数据库设计时最简单的方案是users表里只记录inviter_id表示这个用户是被谁带来的。完整层级路径依赖后端实时向上递归查询获得但是递归在层级深、请求并发高时性能很差所以常见做法是建一张user_ancestors表或使用缓存在用户被邀请时就把这条链记录完整查询时一次命中。CREATE TABLE users ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, inviter_id BIGINT UNSIGNED NOT NULL DEFAULT 0, invite_code VARCHAR(16) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_inviter_id (inviter_id) ); CREATE TABLE user_ancestors ( user_id BIGINT UNSIGNED NOT NULL, ancestor_id BIGINT UNSIGNED NOT NULL, depth TINYINT UNSIGNED NOT NULL, PRIMARY KEY (user_id, ancestor_id) );user_ancestors表里每行记录某用户与他的某个上级的关系深度depth1是直接邀请人depth2是上上级。这样查询一个人所有的上级时一条SELECT ancestor_id, depth FROM user_ancestors WHERE user_id ?就完成不需要递归。写用户注册接口时事务内同时写入users和user_ancestors。邀请人注册时把邀请人的所有上级加上自己就构成新用户的全链路。# 伪代码以Python/Flask为例 def build_ancestors(cur, user_id, invite_id): if invite_id 0: return level 1 cur.execute( INSERT INTO user_ancestors (user_id, ancestor_id, depth) VALUES (%s, %s, %s), (user_id, invite_id, level) ) cur.execute( SELECT ancestor_id, depth FROM user_ancestors WHERE user_id %s, (invite_id,) ) for ancestor_id, depth in cur.fetchall(): cur.execute( INSERT INTO user_ancestors (user_id, ancestor_id, depth) VALUES (%s, %s, %s), (user_id, ancestor_id, depth 1) ) conn.commit()这段的重点是密封新用户的关系链完全由邀请人的关系链复制而来新用户以后无论邀请多少人都不会影响自身链路的完整性。运营后台调整分佣层级时只要改这里的depth限制比如只允许深度2的上级进入分润结算范围非常方便。4.2 分润金额用整数“分”存储避免浮点误差多级分佣系统里最影响财务对账准确率的是浮点金额。比如奖品价值188.88元一级分润比例8%二级分润比例3%计算时如果用浮点数一级分润15.1104元继续往下算二级浮点尾数会不断累积误差月底对账差一分钱都很难查。所以金额字段一律以分存储amount_cent INT。CREATE TABLE commission_settlements ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, prize_log_id BIGINT UNSIGNED NOT NULL, winner_user_id BIGINT UNSIGNED NOT NULL, beneficiary_user_id BIGINT UNSIGNED NOT NULL, depth TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT 1一级分佣, 2二级分佣, amount_cent INT UNSIGNED NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待结算,1已入账,2作废, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_beneficiary_status (beneficiary_user_id, status) );分润计算逻辑写在服务端一个独立service里中奖接口被调用时同步生成分佣记录但金额先冻结等到奖品状态变成已核销/已发货时才真正入账。冻结的意义是防止用户中奖后取消订单、退款分润已经打出去造成资损。运营后台里status0的记录数量就是待结算总额这个数字应当能和奖品核销报表对得上。4.3 用Redis事务避免并发分润重复入账用户基数大了以后两个人同时中奖并且受益人是同一个上级如果直接执行读余额-加上分润-写余额并发瞬间会互相覆盖造成余额丢失。解决手段是利用Redis单线程的原子自增特性MULTI INCRBY user:balance:10086 1500 SADD user:settled:10086 prize_log_202501010001 EXECINCRBY对Key执行原子增加Redis天然串行执行不会出现两个请求同时读到同一旧值的情况。更进一步把查询是否已结算和写入已结算集合放进一个Lua脚本保证同一奖品格子不会重复给上级发两次分佣。local prize_id KEYS[1] local amount tonumber(ARGV[1]) local balance_key KEYS[2] if redis.call(SISMEMBER, settled_logs, prize_id) 0 then redis.call(SADD, settled_logs, prize_id) redis.call(INCRBY, balance_key, amount) return 1 else return 0 end在service层调用EVAL执行这段脚本返回1表示分佣已入账返回0表示该奖品已处理过直接忽略。余额落库时用异步对账Redis里保存总余额和总入账记录MySQL表在低峰时再同步。这里不用事务保存历史也是为了性能分佣记录本身是只读的流水不存在回滚需求。4.4 分佣层级配置不要超过两级标题强调多级分佣但实际运营里建议把层级深度限制在两级以内原因不是技术而是资金链风险与链路复杂度。三级及以上的分佣系统在每一层都要追加对上游用户活跃度的判断否则刷子伪装成一层层邀请链就能靠虚假流量骗走分佣。技术实现的建议配置如下参数推荐值说明一级分佣比例5%-20%直接邀请人拿大头二级分佣比例2%-8%间接邀请人拿小头分佣上下限0.01元-500元低于0.01元不入账防骚扰结算延迟奖品核销后T1给取消订单留出窗口分佣比例的读取不要写死在代码里放到运营后台的一张比例配置表。运营者可以根据活动阶段动态调整比如拉新期把一级比例调到20%活动末期降回8%。配置表要有生效时间和失效时间避免正在跑的分润任务引用到修改后的比例产生纠纷。5. 压测与加固让zip包部署的刮刮乐不被薅穿5.1 用AB压测验证接口并发上限部署上线前最值得做的一步是给/api/v1/prize/draw接口做压力测试。常见的薅羊毛手段是脚本并发轮询中奖接口因为刮刮乐前端慢脚本可以绕过前端直接请求后端。先用ApacheBench模拟100并发1000请求看接口的QPS和错误率。ab -n 1000 -c 100 -p draw_body.txt -T application/json https://scratch.example.com/api/v1/prize/draw-c 100是并发用户数-n 1000是总请求数draw_body.txt里放一份带userId和签名参数的合法JSON请求体。压测结果关注两个指标Requests per second低于50说明后端需要扩容或加缓存Failed requests如果非零就要查是限流问题还是数据库连接池打满。分佣系统的瓶颈通常不在抽奖算法而在user_ancestors表查询和Redis写入压测时盯住Redis的used_memory和MySQL的Threads_running。提示正式环境压测前要先从nginx访问日志里把测试IP加白避免误伤真实用户。5.2 防重放与防伪装的签名校验接口被脚本刷的另一个防护点是给每次请求配一个sign字段用服务器下发的token加上时间戳和用户id做HMAC-SHA256签名。实现方式是每次用户进入页面先调用/api/v1/user/init拿到nonce后续抽奖请求用nonce加timestamp拼一个签名。后端校验签名有效且时间戳在60秒内才受理。同时加一个极简的频控Redis ZSet记录每个userId在1分钟内的抽奖次数超过3次直接返回错误。// Java伪代码简化签名生成 String payload userId : timestamp : nonce; String sign HMAC_SHA256(payload, serverSecret); // 请求参数: userIdxxtimestampxxnoncexxsignxx这里的serverSecret绝不能下发到前端代码里它保存在后端环境变量。前端只负责从配置接口拉取nonce签名本身也不在前端完成而是前端把userId和timestamp提交给后端一个换签接口后端签好再返回这样即使抓包也拿不到密钥明文。校验不通过时接口返回401而不是200避免脚本根据返回码盲试。5.3 zip包内敏感信息清理与二次打包防御从网上下载的H5源码zip包第一件事不是解压到服务器而是搜索整个目录确认没有泄露重要的密钥。常见泄露源有三个前端代码里的接口地址写成了开发库IP、manifest配置里残留了打包者的微信AppSecret、数据库连接串带明文密码。用grep把风险文件一次性找出来这是绝对安全的做法。zipinfo lottery.zip | grep -E \.(js|json|conf|env)$ unzip lottery.zip -d /tmp/lottery_check grep -rE (secret|appSecret|password|token) /tmp/lottery_check/src --include*.js | head -20清理后修改前端的签名密钥再重新构建。二次打包防御上在index.html里嵌入一个由后端接口动态下发的前端环境指纹指纹基于域名、package版本号和构建时间计算。如果检测到页面被整体拷贝到别的域名初始化接口返回的指纹不匹配页面自动展示空白。这个方案没法完全阻止静态资源被盗用但能让盗用者无法直接接入你的后端分佣接口。最后值得动手验证的是刮奖识别是否对服务端结果完全透明。在浏览器开发者工具里清掉localStorage重新刷新页面直接调用抽奖结果接口确认每一步返回都来自后端而不是前端缓存再检查用户中奖后commission_settlements表是否生成了对应记录。这样一条链路验证完成这套免公众号、支持多级分佣的H5刮刮乐项目就算可以交给运营了。本文还有配套的精品资源点击获取