Node.js 退款成功但状态没回写:我用幂等退款单、通知重试和对账补齐闭环
一次微信小程序退款通知丢失,让订单状态停在退款中。我把退款请求、状态机、异步通知、主动查询和定时对账串成闭环,避免重复退款和状态不一致。

作者
ZHOUYI
晚上客服转来一个用户投诉:微信小程序里退款已经到账,但订单详情还显示退款中。我先查 Nuxt 后台,发现页面读取的状态确实没变;再查 Node.js 日志,第三方退款回调没有成功落库。那次我才意识到,支付退款不能只依赖一次异步通知。
先给退款建一张独立单据
以前我会在订单文档上加 refundStatus,但并发重试时很容易互相覆盖。后来改成每次退款生成唯一 out_refund_no,在 MongoDB 里单独建 refunds 集合,并加唯一索引。状态只允许按状态机推进:pending、processing、success、failed。这样即使用户连点、接口超时、服务重启,也能靠幂等避免重复退款。
- 请求前:先落本地退款单,状态 pending
- 调用渠道:只允许 pending 单据继续
- 回调或查询:只按退款单号推进状态
- 前端展示:以本地状态为准,不盲信跳转结果
异步通知要验签、重试、可查询
通知到达后先验签,再按 out_refund_no 找本地退款单。只有状态允许才更新,避免重复通知把成功改成异常。如果数据库写入失败,我不会直接返回成功,而是把消息放进 Redis 延迟队列,等一会儿再消费。用户端也不能只等通知:小程序页面可以轮询订单状态,服务端同时提供主动查询接口,把渠道结果同步回本地。
对账补偿是最后一道闸
定时任务每隔几分钟扫描最近仍处于 processing 的退款单,调用支付平台查询。若渠道已成功,就回写 success 并更新订单;若失败,就记录错误码、触发告警,必要时让人工介入。任务用 Redis 锁防止多实例重复跑,扫描范围也要限制,避免一次拉太多把数据库打慢。
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 少一点,用户信任和业务数据就稳一点。