微信公众号自定义菜单的事件推送机制,是很多运营活动入口的技术基础。当用户点击配置了特定Key的菜单项时,微信服务器会向开发者填写的服务器地址发送一条XML格式的消息,其中包含了用户OpenID、公众号原始ID以及菜单对应的EventKey。我们可以利用这个机制,在用户点击“抽奖”菜单时,引导其进入一个H5抽奖页面,并在后端完成中奖判定后把结果展示出来。

自定义菜单配置与事件推送接收
要实现点击菜单弹出抽奖转盘,第一步是在公众号后台或通过微信接口创建自定义菜单。菜单类型应选择view或click。如果希望直接打开网页,用view并填写抽奖页URL;若需要先走事件再跳转,则用click并在EventKey里标记“LOTTERY”。使用click方式时,微信不会自动打开页面,而是推送事件,开发者服务器收到后返回一条图文消息或文本消息引导用户点击链接,或者结合网页授权直接下发跳转地址。
接收事件推送的接口必须能处理微信的POST请求。下面是一段Node.js的Express示例代码,用于接收并解析XML事件。注意微信推送的是原始XML,需要用到xml2js之类的库转换。代码中我们判断MsgType为event且Event为CLICK,再比对EventKey。
const express = require('express');
const bodyParser = require('body-parser');
const xml2js = require('xml2js');
const app = express();
app.use(bodyParser.text({ type: '*/xml' }));
app.post('/wechat', (req, res) => {
xml2js.parseString(req.body, (err, result) => {
if (err) { res.send(''); return; }
const xml = result.xml;
const msgType = xml.MsgType[0];
const event = xml.Event ? xml.Event[0] : '';
const eventKey = xml.EventKey ? xml.EventKey[0] : '';
if (msgType === 'event' && event === 'CLICK' && eventKey === 'LOTTERY') {
// 构造图文消息引导打开抽奖页
const openid = xml.FromUserName[0];
const reply = `<xml>
<ToUserName><![CDATA[${openid}]></ToUserName>
<FromUserName><![CDATA[${xml.ToUserName[0]}]></FromUserName>
<CreateTime>${Date.now()}</CreateTime>
<MsgType><![CDATA[news]]></MsgType>
<Articles>
<item>
<Title><![CDATA[幸运大转盘]]></Title>
<Description><![CDATA[点击进入抽奖]]></Description>
<Url><![CDATA[https://ipipp.com/lottery?openid=${openid}]></Url>
</item>
</Articles>
</xml>`;
res.send(reply);
} else {
res.send('');
}
});
});
app.listen(80);
上述方式虽然多一步交互,但能有效绑定OpenID并防止未授权访问。如果采用view菜单,抽奖页自身需要通过微信网页授权(snsapi_base)获取OpenID,否则无法在后端关联用户。两种方案各有优劣:click更可控但体验稍重,view流畅但授权逻辑要前移。实际项目中,建议对高价值奖品用click+数据库落库校验,普通活动可用view简化。
前端抽奖转盘的实现与结果展示
抽奖转盘的前端通常由一个圆形背景图加上可旋转指针或圆盘构成。核心是使用CSS的transform: rotate()配合transition实现平滑动画。后端在用户进入页面时先不返回结果,而是等用户点击“开始”按钮后,前端调用接口/api/draw,由后端计算中奖奖项并返回角度与奖品名,前端再旋转到对应扇区。
这样可以避免把中奖概率写死在前端被人篡改。下面是一段简化版的前端旋转逻辑。我们假设转盘分6等份,每份60度,后端告知中奖索引(0到5),我们计算目标角度并加上多圈旋转让动画自然。
<div id="turntable" style="width:300px;height:300px;transition:transform 4s ease;">
<img src="https://ipipp.com/disk.png" style="width:100%;" />
</div>
<button onclick="startDraw()">开始抽奖</button>
<script>
let current = 0;
function startDraw() {
fetch('/api/draw').then(r => r.json()).then(data => {
const seg = 360 / 6;
const target = 360 * 5 + (data.index * seg);
const el = document.getElementById('turntable');
el.style.transform = 'rotate(' + target + 'deg)';
setTimeout(() => {
alert('恭喜获得:' + data.prize);
}, 4200);
});
}
</script>
中奖结果展示不应只依赖前端弹窗。后端返回数据后,前端可以把结果页渲染出来,同时后端已经将本次抽奖记录写入数据库,包含OpenID、奖品、时间、IP。这样即便用户截图篡改,后台记录仍是真实依据。对于未中奖情况,也建议返回固定文案“谢谢参与”并展示安慰奖,保持活动完整性。
在视觉层面,转盘扇区颜色和文案要清晰,避免用户看错奖项。如果奖品较多,可以用八等分或配合高亮动画。移动端需考虑rem适配,防止在低端机出现错位。交互上要禁用重复点击,在请求未完成前将按钮置灰,避免并发请求导致多次扣减抽奖次数。
后端中奖算法与防刷控制
后端抽奖接口是整个活动公平性的关键。常见做法是给每个奖品配置一个权重(概率),用累计区间随机数法抽取。例如一等奖权重1,二等奖权重10,谢谢参与权重89,总和100,随机出1到100的数落入哪个区间即中对应奖。同时要在数据库对用户OpenID做次数限制,比如每人每天三次。
下面是一段Python Flask的抽奖接口示例,演示了权重抽取与次数校验。这里使用redis做限流,用random.randint选奖项,并将结果存MySQL。注意微信EventKey里的用户标识要和网页授权OpenID一致,否则限流失效。
import random
from flask import Flask, request, jsonify
import redis
import pymysql
app = Flask(__name__)
r = redis.Redis(host='127.0.0.1', port=6379)
db = pymysql.connect(host='127.0.0.1', user='root', password='', db='lottery')
PRIZES = [('一等奖', 1), ('二等奖', 10), ('谢谢参与', 89)]
@app.route('/api/draw')
def draw():
openid = request.args.get('openid')
key = 'draw:' + openid
if r.incr(key) > 3:
return jsonify({'index': 2, 'prize': '次数已用完'})
r.expire(key, 86400)
roll = random.randint(1, 100)
cum = 0
idx = 0
for name, weight in PRIZES:
cum += weight
if roll <= cum:
cursor = db.cursor()
cursor.execute("INSERT INTO record(openid,prize) VALUES(%s,%s)", (openid, name))
db.commit()
return jsonify({'index': idx, 'prize': name})
idx += 1
app.run(port=5000)
防刷的另一面是异常监控。如果某个IP或OpenID在短时间大量中奖,需触发告警并人工核对。微信公众号场景里,OpenID无法伪造但可被多账号绕过,因此活动页面应加图形验证码或绑定手机号再领奖。事件推送本身只解决“入口”问题,真正防刷要靠业务层多重校验。
最后,中奖结果展示页建议做服务端渲染,把奖项名称、核销二维码直接输出,减少前端篡改空间。结合自定义菜单事件推送,整体链路为:用户点菜单→微信推事件→后台识Key→引导H5→前端求接口→后端算奖→落库返结果→页面展转盘。这套结构既满足体验,也守住公平底线。