点餐系统的体验闭环里,"下单即通知"是商家最刚需的能力:顾客支付成功 → 后厨小票打印 → 商家端接单提醒 → 顾客取餐号推送。本文以微信小程序生态为例,把订阅消息与支付回调这两个"最常出事"的模块讲透。

一、订阅消息:先搞清"一次性"的模型

微信订阅消息分两类:

类型授权模型点餐场景用法
一次性订阅用户授权 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' });

最容易翻车的三点:

  1. 用解析后的 JSON 验签:签名基于原始报文,先 JSON.parse 再验必失败
  2. 没有幂等:微信回调按"15s/15s/30s/3m/10m/20m/30m/30m..."重试,无幂等就是重复发货
  3. 处理失败不回 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 预扣库存
  ]);
}

小结

通知与支付的可靠性工程可以浓缩成六个字:验签、幂等、状态机。这三件事做到了,"丢单""重复扣款""收不到提醒"这类客诉会从每周都有变成几乎绝迹。