·更新于 2026年10月25日· 11 次浏览 Node.js支付系统退款MongoDBRedis微信小程序Vue3NuxtBUG解决工程实践

Node.js 退款成功但状态没回写:我用幂等退款单、通知重试和对账补齐闭环

一次微信小程序退款通知丢失,让订单状态停在退款中。我把退款请求、状态机、异步通知、主动查询和定时对账串成闭环,避免重复退款和状态不一致。

Node.js 退款成功但状态没回写:我用幂等退款单、通知重试和对账补齐闭环 封面
ZHOUYI

作者

ZHOUYI

晚上客服转来一个用户投诉:微信小程序里退款已经到账,但订单详情还显示退款中。我先查 Nuxt 后台,发现页面读取的状态确实没变;再查 Node.js 日志,第三方退款回调没有成功落库。那次我才意识到,支付退款不能只依赖一次异步通知。

先给退款建一张独立单据

以前我会在订单文档上加 refundStatus,但并发重试时很容易互相覆盖。后来改成每次退款生成唯一 out_refund_no,在 MongoDB 里单独建 refunds 集合,并加唯一索引。状态只允许按状态机推进:pending、processing、success、failed。这样即使用户连点、接口超时、服务重启,也能靠幂等避免重复退款。

  • 请求前:先落本地退款单,状态 pending
  • 调用渠道:只允许 pending 单据继续
  • 回调或查询:只按退款单号推进状态
  • 前端展示:以本地状态为准,不盲信跳转结果

异步通知要验签、重试、可查询

通知到达后先验签,再按 out_refund_no 找本地退款单。只有状态允许才更新,避免重复通知把成功改成异常。如果数据库写入失败,我不会直接返回成功,而是把消息放进 Redis 延迟队列,等一会儿再消费。用户端也不能只等通知:小程序页面可以轮询订单状态,服务端同时提供主动查询接口,把渠道结果同步回本地。

对账补偿是最后一道闸

定时任务每隔几分钟扫描最近仍处于 processing 的退款单,调用支付平台查询。若渠道已成功,就回写 success 并更新订单;若失败,就记录错误码、触发告警,必要时让人工介入。任务用 Redis 锁防止多实例重复跑,扫描范围也要限制,避免一次拉太多把数据库打慢。

js
async function refund(orderId, amount) {
  const outRefundNo = `refund_${orderId}`;
  await db.refunds.updateOne(
    { outRefundNo },
    { $setOnInsert: { outRefundNo, orderId, amount, status: 'pending' } },
    { upsert: true }
  );
  const doc = await db.refunds.findOne({ outRefundNo });
  if (doc.status !== 'pending') return doc;
  const res = await payClient.refund({ outRefundNo, amount });
  await db.refunds.updateOne({ outRefundNo, status: 'pending' }, { $set: { status: 'processing', channel: res.id } });
  return { outRefundNo, status: 'processing' };
}

“支付链路里,不要相信“通知一定会到”。本地单据、状态机、重试和对账查询,缺一不可。”

补齐之后,退款链路变成闭环:请求幂等、通知可重试、查询可兜底、对账可补偿。Vue3 前端和 Nuxt 后台都只读同一份退款单状态,排查问题时也能根据 out_refund_no 一路追到渠道单号。对做内容站和广告收入的人来说,支付类 BUG 少一点,用户信任和业务数据就稳一点。

Guestbook

留言

若这篇文章也曾停在你心上,留一行字吧,我会看的。

先确认一下邮箱

多这一步,是为了少些机器骚扰,也让我能更踏实地读每一条真人写下的字。验证码 10 分钟内有效;同一邮箱 60 秒内只能发一封。

本文留言

共 0 条已加载 0 条

这里还静悄悄的——愿意做第一个留下脚印的人吗?