公众号运营中最常见的玩法之一,就是把自定义菜单和秒杀活动绑定起来:用户点一下菜单按钮,立刻进入秒杀页面,看到倒计时和抢购按钮,气氛一下就起来了。不过不少开发者对这里面的链路并不清楚——菜单点击事件是怎么推送到服务器的?倒计时怎么和服务器时间保持一致?抢购怎么防止超卖?这篇文章就把整条链路拆开讲清楚。

一、自定义菜单的两种类型与点击事件推送机制
先厘清一个容易混淆的概念。公众号自定义菜单的按钮有click和view两种典型类型:view类型点击后直接跳转一个 URL,不经过你的服务器;click类型点击后,微信会把一次事件推送 POST 到你在公众号后台配置的服务器地址上,你的服务端可以拿到用户的 openid,再决定返回什么内容或触发什么动作。
做秒杀活动一般推荐用 click 类型,或者用 view 类型但 URL 带上网页授权。如果希望在用户点击菜单时先收到一次事件通知(比如埋点统计、下发客服消息预热),就必须用 click。创建菜单的接口请求示例如下:
POST https://api.weixin.qq.com/cgi-bin/menu/create?access_token=ACCESS_TOKEN
{
"button": [
{
"type": "click",
"name": "整点秒杀",
"key": "MENU_SECKILL"
},
{
"type": "view",
"name": "活动规则",
"url": "https://你的域名/seckill/rule"
}
]
}
当用户点击「整点秒杀」按钮时,微信会向你的服务器推送一段 XML 报文,关键字段是 EventKey,也就是创建菜单时设置的 key。服务端解析这个 key 就知道用户点了哪个菜单。报文大致长这样:
<xml> <ToUserName><![CDATA[gh_xxxxxx]]></ToUserName> <FromUserName><![CDATA[oUser_openid]]></FromUserName> <CreateTime>1700000000</CreateTime> <MsgType><![CDATA[event]]></MsgType> <Event><![CDATA[CLICK]]></Event> <EventKey><![CDATA[MENU_SECKILL]]></EventKey> </xml>
这里有个细节要注意:事件推送要求你的服务器在 5 秒内响应,否则微信会重试三次。做秒杀预热时,收到事件后可以先记录埋点、下发一条客服消息告知活动开始时间,然后立刻回复 success 或空串,不要在事件处理线程里做重活。
二、从菜单点击到秒杀页面:网页授权与倒计时实现
click 事件本身没法直接让用户进入网页,页面跳转还需要配合「客服消息 + 链接卡片」或者干脆改用带网页授权的 view 菜单。更常见的组合方案是:菜单配置成 view 类型,URL 指向网页授权地址,用户点击后经过 OAuth 拿到 openid,再跳转到秒杀 H5 页面,服务端同时知道这个 openid 有没有资格参与秒杀(比如是否关注满七天、是否是会员)。
倒计时是秒杀页面的灵魂,但很多实现都栽在了一个坑上:直接用用户手机的本地时间做倒计时。一旦用户手机时间不准,按钮就会提前或延后解锁。正确做法是页面加载时从服务端拉取「服务器当前时间 + 活动开始时间 + 活动结束时间」,以服务器时间为基准计算偏移量:
// 前端秒杀倒计时核心逻辑
async function initSeckill() {
const res = await fetch('/api/seckill/info?actId=1001');
const data = await res.json();
// 计算客户端与服务端的时间偏移
const offset = data.serverTime * 1000 - Date.now();
tick(data.startTime, data.endTime, offset);
}
function tick(start, end, offset) {
const timer = setInterval(() => {
// 用客户端时间加偏移,得到近似的服务器时间
const now = Date.now() + offset;
const diff = start - now;
const btn = document.getElementById('seckillBtn');
if (diff > 0) {
btn.disabled = true;
btn.textContent = formatCountdown(diff);
} else if (now < end) {
btn.disabled = false;
btn.textContent = '立即抢购';
clearInterval(timer);
} else {
btn.disabled = true;
btn.textContent = '活动已结束';
clearInterval(timer);
}
}, 200);
}
function formatCountdown(ms) {
const s = Math.floor(ms / 1000);
const h = String(Math.floor(s / 3600)).padStart(2, '0');
const m = String(Math.floor(s % 3600 / 60)).padStart(2, '0');
const sec = String(s % 60).padStart(2, '0');
return `距开始 ${h}:${m}:${sec}`;
}
两点补充说明:一是倒计时间隔用 200 毫秒而不是 1 秒,可以避免整数秒切换时的视觉抖动;二是按钮解锁只是前端体验,真正的抢购资格判定必须放在服务端,前端的时间永远不可信。
三、后端抢购接口设计与防超卖方案
用户点击「立即抢购」后,前端发起抢购请求,后端才是真正的战场。秒杀接口最核心的问题是高并发下的库存一致性。如果直接在数据库里执行 update stock set num = num - 1 再判断影响行数,在并发不高时没问题,但瞬时几千个请求打过来时,数据库连接池会先被打爆。
比较稳妥的方案是把库存预热到 Redis,用原子递减操作挡住大部分流量,只有扣减成功的请求才落库创建订单。伪代码如下:
public SeckillResult doSeckill(Long actId, String openid) {
String stockKey = "seckill:stock:" + actId;
String userKey = "seckill:users:" + actId;
// 判断是否重复抢购,同一个用户只允许抢一次
if (!jedis.sadd(userKey, openid)) {
return SeckillResult.repeat();
}
// 原子递减库存,小于零说明已抢完
long stock = jedis.decr(stockKey);
if (stock < 0) {
return SeckillResult.soldOut();
}
// 消息队列异步创建订单,快速响应用户
mqSender.send(new OrderMessage(actId, openid));
return SeckillResult.success();
}
这个方案里有几个容易被忽略的细节。第一,decr 之后库存小于零时要马上回滚一次 incr,否则极端情况下库存会变成负数并且越负越多;第二,用户去重用 Redis 的 Set 即可,不需要查数据库;第三,订单创建走消息队列异步化,用户端立刻收到「抢购成功,订单生成中」的响应,体验更流畅;第四,接口层最好再加一道限流,比如每秒只放行 500 个请求进 Redis,其余直接返回「太火爆了再试试」,保护后端整体稳定。
最后别忘了整个链路的兜底:活动开始前把数据库库存同步到 Redis,活动结束后对账 Redis 扣减数量和实际订单数是否一致,发现差异及时排查。菜单入口、事件推送、倒计时页面、抢购接口这四段链路各自独立又环环相扣,任何一段出问题都会影响活动效果,上线前建议用压测工具把秒杀那一秒的峰值流量完整模拟一遍。