简介:面向微信平台互动营销开发者的“微信开发砸金蛋”实例包,适合具备HTML5、JavaScript与PHP基础、希望快速理解公众号小游戏完整链路的初中级开发者参考。内容围绕砸金蛋游戏的页面呈现、交互逻辑与后端数据处理展开,可用于品牌推广、节日促销或用户互动等活动场景的原型搭建。压缩包共6个文件,以4张PNG图片、1个PHP脚本和1个HTML页面组成,分别承担界面素材、服务端请求处理与前端结构展示,整体约91KB,体量轻便,便于查阅与二次修改。已有218人学习,说明其在微信小游戏入门与营销活动实现方面具有一定参考价值。通过学习可了解前端动画与点击反馈的组织方式、奖品与用户数据的接口衔接思路,以及前后端分离的简易实现结构,为自行搭建抽奖互动页面提供可复用的代码骨架和调试切入点。
1. 砸金蛋活动页:为什么很多团队把它做成一次性的抽奖壳
砸金蛋活动页在社交平台里看起来只是三个蛋加一段动画,真正上线后却要同时处理活动入口、用户身份、库存、概率和发奖。某开发者把第一版做成纯前端随机数,活动开始十分钟就被重复请求打穿,这种翻车在节日活动里并不少见。这个项目适合两类人:手里有源码包、想把活动跑起来的开发者,以及准备在现有活动框架上二次开发的人。它要解决的不是动画好不好看,而是从点击金蛋到奖品落袋的链路能不能扛住并发、能不能查得清。
2. 技术选型与页面结构:一个 H5 活动从入口到发奖的骨架
砸金蛋这种活动页,前端看起来是动画,后端看起来是抽奖接口,真正决定能不能复用的是中间的库存、记录和发奖链路。常见做法是把项目拆成三块:H5 活动页、抽奖服务、数据与缓存。前端负责展示金蛋、锤子动画和结果弹层,后端负责身份校验、概率计算、库存扣减和中奖记录,MySQL 存活动配置和流水,Redis 扛高频库存与限流。不要一上来就盯着页面皮肤改,先把这条链路对清楚,后面换奖品、换活动时间才不会牵连代码。
| 层 | 常见选型 | 在砸金蛋场景里的职责 |
|---|---|---|
| H5 页面 | Vue 组合式写法或原生 JS + CSS/Canvas 动画 | 金蛋点击、锤子动画、结果弹层、分享参数 |
| 接口服务 | Spring Boot / Node.js / PHP 均可 | 活动校验、抽奖、记录、发奖补偿 |
| 关系库 | MySQL | 活动、奖品、抽奖记录、发奖状态 |
| 缓存 | Redis | 库存预扣、日限、幂等键、接口限流 |
| 部署 | Nginx + 静态资源 | 活动页缓存、接口反向代理、灰度入口 |
这张表不是让所有人都换技术栈,而是让你拿到源码后知道该看哪几层。前端目录里如果只有页面和图片,没有api、utils这类拆分,大概率抽奖逻辑也堆在页面里;后端如果只有 Controller 没有 Service 和库存层,后面加保底、加黑名单会很痛苦。常见做法是让活动配置和奖品配置走数据库,页面只拿活动 ID 和展示字段,不要把概率写死在前端 JS 里。
2.1 前端页面与动画层:请求和动画要并行
砸金蛋的动画通常分三段:点击锁定、锤子落下、金蛋裂开并弹出结果。很多新手会写成“动画播完再发请求”,结果网络一慢,用户以为没点上,连续点五六次,后端如果没有幂等就会多抽。更稳的写法是点击后立刻锁定按钮,同时发抽奖请求,动画只负责过渡,结果以接口返回为准。前端不需要知道权重和中奖概率,它只拿prizeId、prizeName、status这类展示字段。
// web/src/components/EggGrid/index.js export function bindEggEvents(root, onDraw) { root.addEventListener('click', async (event) => { const egg = event.target.closest('.egg'); if (!egg || egg.dataset.locked === '1') return; // 先锁住当前金蛋,避免连点造成重复请求 egg.dataset.locked = '1'; egg.classList.add('is-breaking'); const requestId = crypto.randomUUID(); try { const result = await onDraw({ eggId: egg.dataset.eggId, requestId }); // 动画至少播 600ms,避免结果弹得太突兀 await new Promise((resolve) => setTimeout(resolve, 600)); showResult(result); } catch (error) { egg.dataset.locked = '0'; egg.classList.remove('is-breaking'); showToast('网络开小差了,请重试'); } }); }这段代码的关键点是requestId和dataset.locked。requestId由前端生成,后端拿它做幂等键,同一个用户同一个请求 ID 重复提交只返回第一次的结果;locked只解决当前页面连点,挡不住脚本重放,所以不能当前端防刷的全部。onDraw是抽奖接口的封装,参数里带eggId是为了让后端知道用户点的是哪个蛋,但奖品仍然由后端权重决定,前端传的蛋 ID 不能直接映射奖品。
2.2 后端服务与数据表:四张表撑起活动
后端不要把所有字段塞进一张大表。常见做法是活动表、奖品表、抽奖记录表、发奖记录表分开。活动表管时间、状态、日限;奖品表管库存、权重、奖品类型;抽奖记录表管用户每次点击的结果和幂等键;发奖记录表管异步发奖状态。这样活动结束后对账,能直接按状态筛出“发放中”和“发放失败”的记录,不用翻应用日志。
-- sql/schema.sql CREATE TABLE activity ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(80) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, daily_limit INT NOT NULL DEFAULT 3, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE prize ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id BIGINT NOT NULL, name VARCHAR(80) NOT NULL, weight INT NOT NULL DEFAULT 0, stock INT NOT NULL DEFAULT 0, daily_stock INT NOT NULL DEFAULT 0, prize_type TINYINT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 1, INDEX idx_activity (activity_id) ); CREATE TABLE draw_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id BIGINT NOT NULL, user_id VARCHAR(64) NOT NULL, prize_id BIGINT NOT NULL, request_id VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request (activity_id, user_id, request_id), INDEX idx_user_time (user_id, created_at) ); CREATE TABLE award_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, draw_id BIGINT NOT NULL, user_id VARCHAR(64) NOT NULL, prize_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, retry_count INT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_draw (draw_id) );activity.daily_limit控制每个用户每天能砸几次,prize.weight是权重不是百分比,总和可以是 100 也可以是 1000,后端按区间累加即可。draw_record上的uk_request是防重复插入的兜底,应用层 Redis 幂等失效时数据库还能挡一次。award_record.status我一般用 0 待发放、1 发放中、2 成功、3 失败,定时任务只扫 0 和 3,避免把成功记录反复推给发奖渠道。字段长度要按实际用户标识调整,不要照抄 64 位就上线。
2.3 目录结构:按活动域拆而不是按技术层拆
拿到源码包先看目录。活动类项目最容易出现两种结构:一种按controller、service、mapper平铺,找个抽奖逻辑要跳五个文件夹;另一种按活动域拆,activity、prize、draw、award各自成包。砸金蛋这种带库存和发奖的活动,我更倾向第二种,至少后面加“翻牌”“刮刮卡”时能把公共抽奖逻辑抽出去。
egg-smash/ ├── web/ # H5 活动页 │ ├── src/ │ │ ├── pages/activity/ # 活动入口页 │ │ ├── components/EggGrid/ # 金蛋网格与动画 │ │ ├── api/draw.js # 抽奖接口封装 │ │ └── utils/auth.js # 平台用户标识获取 │ └── vite.config.js ├── server/ │ ├── src/main/java/.../draw/ # 抽奖领域 │ ├── prize/ # 奖品与库存 │ ├── award/ # 发奖与补偿 │ └── config/ # Redis、MQ、拦截器 ├── sql/ │ ├── schema.sql # 表结构 │ └── init_prize.sql # 活动与奖品初始化 └── deploy/ └── nginx.conf # 静态资源与接口代理这个目录树里,web只关心展示和接口调用,server按领域分包,sql负责初始化,deploy放反向代理。跑起来的第一步不是npm install,而是先把sql/schema.sql和init_prize.sql灌进 MySQL,再确认server的配置文件里 Redis、数据库、消息队列地址。下面这组命令按常见 Java + Vite 结构写,如果你的源码包是 Node 或 PHP,把启动命令换成对应脚本即可。
# 1. 初始化数据库,库名按配置文件里的实际名称改 mysql -u root -p egg_smash < sql/schema.sql mysql -u root -p egg_smash < sql/init_prize.sql # 2. 启动缓存,活动库存和日限都依赖它 redis-server --port 6379 # 3. 启动后端,看控制台有没有端口冲突和数据库连接失败 cd server && mvn spring-boot:run # 4. 启动前端,活动页通常带活动 ID 参数 cd web && npm install && npm run dev -- --host 0.0.0.0命令里的egg_smash是示例库名,实际以application.yml或.env为准。--host 0.0.0.0是为了让手机或另一台机器访问开发页,活动页入口一般要带activityId,比如/activity?activityId=1001。如果页面能打开但点金蛋报 401,先看平台用户标识有没有换到;如果报 500,先看 Redis 和 MySQL 是否连上;如果一直转圈,看 Nginx 或 Vite 代理有没有把/api转到后端端口。
3. 核心链路拆解:从点击金蛋到库存扣减的完整请求
一条完整的砸金蛋请求会经过:活动页加载、平台身份识别、点击锁、抽奖接口、活动校验、幂等校验、日限校验、权重抽奖、库存预扣、写抽奖记录、异步发奖。任何一步顺序错了,后面都可能出现“用户中奖了但库存没扣”“库存扣了但记录没写”“记录写了但奖品没发”。这一章按请求顺序拆,代码只保留关键段,参数和失败分支在代码后说明。
3.1 活动入口与身份识别:先拿到稳定的用户标识
活动页通常从平台内嵌浏览器进入,链接里可能带一次性授权码。前端不要每次点击都去换用户标识,常见做法是进入页面先换一次,换到后存本地,后续接口只带用户标识或会话令牌。授权码是一次性的,页面刷新后如果还拿旧码去换,后端会报失效,所以回跳地址要把活动参数保留住。
// web/src/utils/auth.js export async function ensureUserId() { const cached = localStorage.getItem('platform_user_id'); if (cached) return cached; const params = new URLSearchParams(location.search); const code = params.get('code'); if (!code) { // 没有授权码时跳平台授权页,return 参数保留活动 ID const returnUrl = encodeURIComponent(location.href); location.href = `/api/auth/redirect?return=${returnUrl}`; return null; } const res = await fetch(`/api/auth/login?code=${encodeURIComponent(code)}`).then((r) => r.json()); if (res.code !== 0) throw new Error(res.msg || '登录失败'); localStorage.setItem('platform_user_id', res.data.userId); return res.data.userId; }code只用于换userId,换完就不要再拼到业务接口里。returnUrl里保留activityId,否则授权回跳后活动页不知道展示哪个活动。localStorage只存用户标识,不存奖品概率和库存,前端存这些字段等于把配置送给用户改。后端登录接口拿到code后调用平台服务换用户标识,再把会话写进 Redis 或签发短令牌,业务接口从会话里取用户,不信任前端直接传的userId。
3.2 砸蛋接口:幂等、日限、抽奖三件事
抽奖接口是整条链路的核心,参数一般只有活动 ID、金蛋 ID 和请求 ID,用户身份从会话取。下面这段是常见的 Controller 写法,先做活动校验,再用 Redis 做幂等,最后做日限和抽奖。注意幂等键要带活动、用户和请求 ID,不能只带用户,否则用户第二次正常抽奖会被当成重复请求。
@PostMapping("/api/egg/smash") public Result<DrawRecord> smash(@RequestBody SmashRequest req) { // 1. 活动状态与时间窗口校验 Activity activity = activityCache.get(req.getActivityId()); checkActivity(activity); // 2. 幂等:同一个请求 ID 只处理一次,重复请求直接返回旧结果 String idemKey = "egg:idem:" + req.getActivityId() + ":" + req.getUserId() + ":" + req.getRequestId(); Boolean first = redis.opsForValue().setIfAbsent(idemKey, "1", Duration.ofMinutes(10)); if (Boolean.FALSE.equals(first)) { return Result.ok(drawRecordService.findByRequestId(req.getRequestId())); } // 3. 日限:先自增再判断,避免并发下先查后加导致超限 String dayKey = "egg:day:" + req.getUserId() + ":" + LocalDate.now(); Long count = redis.opsForValue().increment(dayKey); if (count != null && count == 1) { redis.expire(dayKey, Duration.ofDays(1)); } if (count != null && count > activity.getDailyLimit()) { return Result.fail("今日次数已用完"); } // 4. 权重抽奖与库存预扣由 drawService 完成 Prize prize = drawService.draw(req.getActivityId()); DrawRecord record = drawRecordService.save(req, prize); // 5. 事务提交后异步发奖,失败由定时任务补偿 mq.send("egg_award", record.getId()); return Result.ok(record); }setIfAbsent的过期时间不能太短,至少覆盖用户重试窗口,十分钟是常见值;太长会占内存,活动结束后可以按活动 ID 清理。dayKey的日期用服务器时区,不要用用户手机时区,否则跨时区用户能多抽。drawService.draw返回的奖品必须已经完成库存预扣,如果库存扣失败要返回兜底奖品,不能让接口抛异常后用户看到“中奖”但记录没落库。mq.send放在事务提交之后,如果项目里没有消息队列,可以先用award_record表加定时任务扫表代替。
3.3 权重与库存:先算区间,再扣库存
权重抽奖不要用一堆if (random < 0.01)写死,奖品一多就难维护。常见做法是把奖品按权重累加,随机数落在哪个区间就命中哪个奖品,命中后再尝试扣库存,扣失败就走兜底奖品。兜底奖品一般是“谢谢参与”或低价值券,库存要足够大,避免用户点了没结果。
public Prize draw(Long activityId) { List<Prize> prizes = prizeCache.list(activityId); int totalWeight = prizes.stream().mapToInt(Prize::getWeight).sum(); int hit = ThreadLocalRandom.current().nextInt(totalWeight); int cursor = 0; for (Prize prize : prizes) { cursor += prize.getWeight(); if (hit < cursor) { // 库存预扣成功才返回该奖品,失败则走兜底 if (stockService.tryDeduct(prize.getId())) { return prize; } return fallbackPrize(activityId); } } return fallbackPrize(activityId); }totalWeight是所有启用奖品的权重和,hit的范围是[0, totalWeight),cursor累加后判断命中区间。tryDeduct必须原子执行,不能先get再set,否则并发下会超卖。fallbackPrize也要有库存或标记为无穷,不能兜底奖品本身扣减失败。这段代码只负责抽奖和预扣,写记录、发奖、回滚库存要放在外层事务或补偿逻辑里,顺序建议是“预扣库存 → 写记录 → 发消息”,任何一步失败都要能按记录回滚。
3.4 前端结果反馈:动画结束不等于请求结束
前端拿到结果后不要立刻弹窗,也不要等动画完全结束才发请求。比较稳的做法是请求和动画并行,动画至少播一段固定时长,结果回来就缓存,等动画结束再展示。这样网络快的时候不会闪一下,网络慢的时候用户也能看到锤子落下,不会以为按钮坏了。
// web/src/pages/activity/index.js async function handleDraw({ eggId, requestId }) { const res = await fetch('/api/egg/smash', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ activityId: activityId, eggId, requestId }) }).then((r) => r.json()); if (res.code !== 0) { throw new Error(res.msg || '抽奖失败'); } return { prizeName: res.data.prizeName, prizeType: res.data.prizeType, recordId: res.data.id, status: res.data.status }; }activityId从页面参数或后端配置取,不要前端写死。requestId由点击时生成并一路带到接口,重试时复用同一个,才能让后端幂等生效。返回值只取展示需要的字段,库存、权重、用户手机号这类信息不要回传。结果弹层里如果带“去领奖”按钮,按钮跳转地址也要从后端拿,前端拼接容易在换发奖渠道时漏改。
4. 避坑与排查:砸金蛋活动上线前后最容易翻车的 5 个点
砸金蛋活动的坑大多不在动画,而在并发和状态。活动开始前十分钟、奖品快抽完、用户疯狂连点,这三个场景最容易翻车。下面五条是血泪经验里出现频率最高的,每条按现象、原因、解决写,方便你上线前对照排查。
4.1 五个高频现象:从连点到超卖
现象一:用户点一下金蛋,结果弹了三次“未中奖”。原因通常是前端按钮没有锁,或者锁了但请求失败后没有及时解锁,用户以为没点上继续点;后端如果没有幂等,三次请求会生成三条记录。解决分两层:前端点击后立刻加locked标记,请求结束无论成功失败都要在finally里解锁;后端用activityId + userId + requestId做唯一键,Redis 幂等和数据库唯一索引都加上,重复请求直接返回第一条记录。
现象二:奖品库存显示还有,但用户抽中后提示“库存不足”。常见原因是库存扣减走的是“先查再减”,并发下两个请求都查到有库存,最后减成负数;或者 Redis 预扣成功了,数据库扣减失败,没有回滚缓存。解决是把库存扣减改成原子操作,Redis 用 Lua 或decrby判断返回值,数据库用update prize set stock = stock - 1 where id = ? and stock > 0看影响行数,任何一层失败都要把另一层补回去。活动结束后按draw_record和prize.stock对账,差一条都要查。
现象三:活动开始后改了奖品概率,线上不生效。原因多半是奖品配置在应用启动时加载到本地缓存,后面只读本地缓存,没有刷新机制;也可能前端把概率写死在 JS 里,后端改了前端没改。解决是奖品配置放数据库或配置中心,后端缓存加版本号或定时刷新,活动进行中改概率要走“新版本生效”而不是直接改内存。前端只展示奖品名称和图片,不参与概率计算。改完概率先用测试活动 ID 跑一百次,看记录分布是否接近权重。
现象四:中奖记录一直是“发放中”,用户来催。常见原因是抽奖事务提交后发消息失败,或者消息消费失败没有重试;也有可能是发奖接口超时,但本地状态没更新。解决是加award_record本地消息表,抽奖记录落库时同时写一条待发放记录,定时任务扫status in (0,3)的记录重试,重试次数超过阈值转人工。发奖接口要支持按drawId查结果,不能只返回成功失败,否则重复调用无法判断上次到底发没发。
现象五:活动页在部分手机打开白屏,但开发机正常。原因可能是静态资源路径写成了绝对路径,部署到子目录后 404;也可能是用了较新的 JS 语法,内嵌浏览器内核不支持;还可能是接口跨域,开发环境配了代理,线上 Nginx 没配。解决是先看浏览器控制台和 Nginx 访问日志,静态资源 404 就改publicPath或base,语法报错就加构建降级,接口跨域就统一走同域反向代理。活动上线前至少在一台低版本内嵌浏览器和一台普通手机上各点一遍。
4.2 排查顺序和兜底开关
线上出问题不要先翻代码,先按“活动状态 → 缓存库存 → 数据库记录 → 发奖队列”的顺序看。活动状态不对,后面都白查;缓存库存对不上,先看预扣和回滚日志;数据库记录看draw_record的status分布;发奖队列看积压和失败重试。下面几条命令是常用的排查入口,库名和 key 前缀按实际项目改。
# 看活动库存 key 的剩余值和过期时间 redis-cli get egg:stock:1001:3 redis-cli ttl egg:stock:1001:3 # 看某个用户当天的抽奖次数 redis-cli get egg:day:user_123:2025-01-01 # 看抽奖记录状态分布,定位是未发奖还是发奖失败 mysql -u root -p -e "select status, count(*) from egg_smash.draw_record where activity_id=1001 group by status;"egg:stock:1001:3里的1001是活动 ID,3是奖品 ID,实际 key 设计要跟代码里的stockKey一致。ttl返回-1表示没有过期时间,活动库存 key 最好在活动结束后统一清理,不要永久占内存。记录状态分布里如果大量status=0,看发奖消费者是不是没启动;如果大量status=3,看发奖接口返回和重试次数。兜底开关要有两个:一个关活动入口,一个把抽奖降级为“只展示不扣库存”,出问题时先止血再定位。
注意:库存预扣和数据库扣减必须能回滚,活动结束后对账会暴露所有没回滚的差额,不要等用户投诉才发现。
5. 数据与风控:奖品库存、抽奖概率和防刷的落地细节
砸金蛋活动能不能稳住,取决于库存扣得准不准、概率配得清不清、刷子挡不挡得住。库存方案没有绝对好坏,活动量小可以直接用数据库乐观锁,活动量大就用 Redis 预扣加数据库落账。概率配置要能解释,不能只写一个随机数。防刷也别一上来就上复杂设备指纹,先把用户维度、接口维度的频率限制做扎实。
5.1 库存扣减:数据库乐观锁和缓存预扣怎么选
数据库乐观锁适合库存少、并发不高的活动,写法简单,事务内就能保证一致。缓存预扣适合瞬时流量大的活动,Redis 扛住第一波,数据库异步落账,但必须补回滚和对账。两种方案可以组合:Redis 做第一道预扣,数据库做最终扣减,任何一边失败都按记录补偿。
| 方案 | 适合场景 | 优点 | 风险点 |
|---|---|---|---|
| 数据库乐观锁 | 库存少、并发低、对账要求高 | 实现简单,事务一致 | 热点行竞争大,容易锁等待 |
| Redis 预扣 | 瞬时流量大、奖品多 | 抗并发,响应快 | 缓存与库不一致,要回滚和对账 |
| 两者组合 | 正式活动、需要兜底 | 兼顾性能和最终一致 | 代码复杂,补偿逻辑要全 |
Redis 预扣的 Lua 脚本要保证“判断库存 + 扣减”在一个原子操作里完成,不能拆成两条命令。下面这段脚本返回 1 表示扣减成功,返回 0 表示库存不足,调用方根据返回值决定走兜底还是回滚。
-- KEYS[1] 库存 key,例如 egg:stock:1001:3 -- ARGV[1] 扣减数量,砸金蛋通常传 1 local stock = tonumber(redis.call('get', KEYS[1]) or '0') if stock < tonumber(ARGV[1]) then return 0 end redis.call('decrby', KEYS[1], ARGV[1]) return 1KEYS[1]要带活动和奖品维度,不能只按奖品 ID,否则两个活动共用同一个奖品会互相扣库存。ARGV[1]一般固定为 1,如果业务支持一次抽多个奖,才传大于 1 的值。脚本执行成功后还要写一条预扣流水,记录drawId、prizeId、扣减数量,数据库扣减失败时按流水回滚。不要只把库存放 Redis 不落库,缓存重启后库存就丢了。
5.2 概率配置:权重表比写死百分比好维护
权重表的好处是加奖品不用改代码,运营改一行配置就能生效。权重不要求总和为 100,后端按总和算区间即可。但权重表要加状态字段,下架奖品不要直接删,改成status=0,否则历史记录关联不上。保底策略可以单独做:用户连续多次未中奖时,提高下一次命中低价值奖品的权重,但保底次数要配置化,不能写死。
-- 初始化奖品,weight 是相对权重,stock 是总库存 INSERT INTO prize (activity_id, name, weight, stock, daily_stock, prize_type, status) VALUES (1001, '一等奖', 1, 10, 1, 1, 1), (1001, '二等奖', 10, 100, 10, 1, 1), (1001, '谢谢参与', 89, 999999, 999999, 0, 1);weight=1不代表 1%,它和10、89一起按总和 100 计算,命中概率分别约 1%、10%、89%。daily_stock是每日库存上限,适合“每天放量”的活动;如果活动不分天,可以把它设成和总库存一样。prize_type=0表示未中奖或兜底奖品,发奖逻辑要跳过这类记录。改权重时先备份旧配置,活动进行中不要直接改正在使用的行,新增一条新版本或走配置中心发布。
5.3 防刷:限流、幂等、黑名单三层就够用
防刷先做接口限流,再做用户维度日限,最后才是黑名单。限流按“用户 + 活动 + 分钟”维度,单用户一分钟超过二三十次就拒绝;日限按活动配置走,不要所有活动都写死三次。设备指纹可以作为辅助,但不要采集敏感信息,前端生成一个随机标识存本地即可,后端结合用户标识和 IP 做风控评分。黑名单要能手工加,也要能自动加,比如同一 IP 下大量用户短时间中奖,先限流再人工看。
// 用户维度分钟限流,返回 true 表示放行 public boolean allow(Long activityId, String userId) { String minute = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); String key = "egg:limit:" + activityId + ":" + userId + ":" + minute; Long count = redis.opsForValue().increment(key); if (count != null && count == 1) { redis.expire(key, Duration.ofMinutes(2)); } return count != null && count <= 30; }minute精确到分钟,key 过期时间设两分钟,避免临界分钟计数丢失。阈值 30 是示例,实际按活动力度调,奖品价值越高阈值越低。这段限流只挡单用户,挡不住批量账号,所以还要有 IP 维度和活动维度的总限流。黑名单命中后直接返回“活动太火爆”,不要返回“你被风控了”,避免刷子调整策略。所有拒绝都要记日志,日志里带活动 ID、用户标识、拒绝原因,方便事后看是不是误伤。
6. 二次开发:把抽奖壳改成可复用的活动模板
这份源码包最适合的二次开发方向,不是换一套金蛋皮肤,而是把抽奖、库存、发奖抽成活动模板。我一般先改配置,不动核心代码:活动时间、日限、奖品权重、库存、兜底文案走数据库或 JSON 配置,前端只读展示字段。这样下一次做“翻牌”或“刮刮卡”,只需要换前端交互层,后端抽奖接口和发奖补偿可以复用。
| 改造点 | 改哪里 | 验证方法 |
|---|---|---|
| 换活动时间 | activity.start_time/end_time | 改到未来时间,确认入口提示未开始 |
| 调奖品概率 | prize.weight | 跑一百次抽奖,看记录分布是否接近权重 |
| 换发奖渠道 | award模块的发送实现 | 用测试用户发一笔,查award_record状态 |
| 换页面皮肤 | web静态资源和组件样式 | 手机和内嵌浏览器各点一遍 |
| 加保底 | drawService外层加保底规则 | 模拟连续未中奖,看下一次是否触发 |
配置建议单独放一个 JSON 或数据库行,不要散落在代码里。下面这份配置是活动维度的最小集合,前端启动时拉一次,后端抽奖时以数据库为准,前端配置只用于展示。
{ "activityId": 1001, "dailyLimit": 3, "prizes": [ { "id": 1, "name": "一等奖", "weight": 1, "stock": 10 }, { "id": 2, "name": "二等奖", "weight": 10, "stock": 100 }, { "id": 3, "name": "谢谢参与", "weight": 89, "stock": 999999 } ] }dailyLimit是每个用户每天可抽次数,weight是相对权重,stock是总库存。改完配置先跑幂等重放:同一个requestId连发三次,确认只产生一条记录;再把库存改成 1,两个用户并发抽,确认只有一个中奖;最后把活动状态改成结束,确认入口和接口都拒绝。从那以后我每次改概率配置都强制走一遍库存回滚和幂等重放,先看日志再开入口。希望帮到你。
本文还有配套的精品资源,点击获取