点餐系统的体验闭环里,"下单即通知"是商家最刚需的能力:顾客支付成功 → 后厨小票打印 → 商家端接单提醒 → 顾客取餐号推送。本文以微信小程序生态为例,把订阅消息与支付回调这两个"最常出事"的模块讲透。
一、订阅消息:先搞清"一次性"的模型
微信订阅消息分两类:
| 类型 | 授权模型 | 点餐场景用法 |
|---|---|---|
| 一次性订阅 | 用户授权 1 次 = 可发 1 条 | 下单时请求授权,支付后推送"接单/取餐通知" |
| 长期订阅 | 仅公共服务类目可申请 | 一般餐饮主体拿不到,别在设计上依赖 |
关键约束:一次性授权只够发一条,所以请求授权的时机必须卡在"用户明确要结果"的动作上:
// 小程序端:提交订单前请求订阅(此时授权转化率最高)
wx.requestSubscribeMessage({
tmplIds: ['ORDER_STATUS_TMPL_ID'],
complete: () => submitOrder(), // 用户拒绝也要继续下单,不阻断主流程
});
服务端在关键节点推送:
async function notifyOrderPaid(order) {
await wxapi.post(`cgi-bin/message/subscribe/send?access_token=${token}`, {
touser: order.openid,
template_id: 'ORDER_STATUS_TMPL_ID',
page: `pages/order/detail?id=${order.id}`, // 点击通知直达订单详情
data: {
thing1: { value: truncate(order.dishesSummary, 20) }, // 字段类型/长度有严格校验
character_string2: { value: order.pickupNo },
amount3: { value: (order.total / 100).toFixed(2) },
phrase4: { value: '已接单' }, // phrase 类型限 5 字以内
},
});
}
实战要点:
- 模板字段类型逐一核对:
thing/phrase/character_string/amount长度与字符集规则不同,超限直接 47001 报错 - 授权量要统计:记录每单是否拿到授权,授权率低于 60% 就要优化请求时机文案
- 通知失败不影响交易:推送失败进重试队列(3 次退避),但绝不能阻塞接单流程
二、支付回调:钱的事,一步都不能省
支付回调的标准处理链,五步缺一不可:
// 1. 验签:用微信平台证书校验请求确为微信发出
const ok = await wechatPay.verifySign({
timestamp: req.headers['wechatpay-timestamp'],
nonce: req.headers['wechatpay-nonce'],
signature: req.headers['wechatpay-signature'],
body: req.rawBody, // 原始报文验签,不能用解析后的对象
});
if (!ok) return res.status(401).end();
// 2. 解密资源(API v3 AES-256-GCM)
const resource = wechatPay.decipherGcm(req.body.resource);
// 3. 幂等:同一 transaction_id 只处理一次
const first = await redis.set(`paid:${resource.transaction_id}`, 1, 'NX', 'EX', 86400);
if (!first) return res.status(200).json({ code: 'SUCCESS' });
// 4. 对账:以微信侧金额为准校验(防止伪造金额)
const order = await db.order.findUnique({ where: { outTradeNo: resource.out_trade_no } });
if (order.total !== Number(resource.amount.total)) {
await alarm('金额不一致', order); // 立即告警人工介入
return res.status(200).json({ code: 'SUCCESS' }); // 先应答防止重试风暴
}
// 5. 状态机推进 + 业务动作(发券、打印、推送),全部幂等
await db.$transaction([
db.order.update({ where: { id: order.id }, data: { status: 'PAID', paidAt: new Date() } }),
db.payRecord.create({ data: { orderId: order.id, transactionId: resource.transaction_id } }),
]);
await queue.add('print-ticket', { orderId: order.id });
res.status(200).json({ code: 'SUCCESS' });
最容易翻车的三点:
- 用解析后的 JSON 验签:签名基于原始报文,先 JSON.parse 再验必失败
- 没有幂等:微信回调按"15s/15s/30s/3m/10m/20m/30m/30m..."重试,无幂等就是重复发货
- 处理失败不回 200:业务异常也建议应答成功后走告警+人工,让微信无限重试打挂接口得不偿失
三、订单状态机:通知与回调共同的骨架
CREATED → PAID → COOKING → READY → COMPLETED
↓ ↘
CANCELED(超时未支付) REFUNDED
约束三条:
- 状态只能单向前进(除取消/退款两个旁支),任何接口不得跳跃修改
- 每次推进先校验当前状态:
UPDATE orders SET status='PAID' WHERE id=? AND status='CREATED',受影响行数为 0 说明已被处理(天然幂等) - 每次推进记录审计日志:谁、何时、从什么状态到什么状态、触发源(回调/人工/超时任务)
四、超时关单:定时任务的正确姿势
下单 15 分钟未支付自动取消,两种实现:
- Redis 过期监听:key 过期事件触发关单——简单但不可靠(过期事件可能延迟/丢失),只适合辅助提醒
- 定时扫描(推荐兜底):每分钟扫
status=CREATED AND createdAt < now-15min,分页批量取消并释放库存
const stale = await db.order.findMany({
where: { status: 'CREATED', createdAt: { lt: new Date(Date.now() - 15 * 60 * 1000) } },
take: 100,
});
for (const o of stale) {
await db.$transaction([
db.order.updateMany({ where: { id: o.id, status: 'CREATED' }, data: { status: 'CANCELED' } }),
restoreStock(o), // 释放 Redis 预扣库存
]);
}
小结
通知与支付的可靠性工程可以浓缩成六个字:验签、幂等、状态机。这三件事做到了,"丢单""重复扣款""收不到提醒"这类客诉会从每周都有变成几乎绝迹。