3.3 支付接入:微信支付与回调验签

场景

整个项目里,微信支付接入是我心理压力最大的一环——钱的事,错不起。开工前我做了两天功课,看文档、抄示例,自以为看懂了。第一次联调那天,我信心满满地按下“立即支付”,模拟器弹出来的不是收银台,而是一串看不懂的错误码。之前看文档时觉得“这不就是个接口调用”,真到自己串通整条链路(下单 → 统一接口 → 前端调起 → 回调 → 订单状态更新),每一环都有专门的名字和证书,环环都要配对,环环都能报错。

最戏剧化的一次是深夜联调回调。统一下单成功了,收银台也弹了,测试账号也付了 0.01 元,订单状态就是不变。我在回调日志里翻了两个小时,最后发现是云函数的默认域名没加进微信支付平台的回调白名单——服务器请求根本进不来,本地却没有任何提示。那天我学到的道理:支付问题的排查,先确认“请求到底有没有到你家服务器”,再谈别的。凌晨三点我把日志截图发给一位接过支付的朋友,他回我一句“支付无难事,只要域名对”,玩笑话里全是过来人的辛酸。

还有一个容易忽略的问题:退款也是走支付链路的。顾客申请退款时,是云函数调退款接口(同样要验签、记流水),退款成功后同样有异步结果通知。退款相关的内容我们在 3.4 节订单状态机里统一讲,这里只提醒一句:退款单号和订单号要建立映射关系,某天顾客拿着退货单来问“我的钱呢”,你能在系统里一眼追到退款的每一步,才是对顾客负责。

还有一桩心理建设值得说:第一次真实收款到账时,朋友拍了张截图给我,0.01 元,她像个孩子一样开心。但我也立刻意识到流水会混入各种小额测试单,必须建立测试订单的标记机制(环境隔离加账号白名单),不然月底对账会乱成一锅粥。这节我们把支付的整条链路完整走一遍:商户配置、统一下单、前端调起、回调验签、错误处理。

方案

支付链路的几个关键角色和职责先分清:

角色 职责 备注
小程序前端 调起收银台、接收支付结果 只负责“付”的交互
createOrder 云函数 统一下单、返回调起参数 订单与支付的桥梁
微信支付平台 处理真实资金流 商户号配置在此
payCallback 云函数 接收支付结果、验签、更新订单 唯一可信来源
前端 onShow 回调延迟时兜底查询订单 状态以云端为准

这里最重要的设计决策是“以谁为准”:付完款顾客跳回小程序,前端拿到的支付结果提示是“参考”,真正可信的只有后端的回调。顾客那边付成功了、回调还没到(网络延迟、并发排队),如果前端直接相信自己的结果把订单标记成已付款,和回调的正式结果一旦冲突就乱套了。所以我们的流程是:前端付完款 → 轮询查询订单状态(查的是云数据库)→ 状态变“已付款”才展示成功页。回调是唯一写入入口,前端只读不写,口径就一条。

回调验签是这节的技术核心。微信支付的 APIv3 回调消息是加密的,要用 APIv3 密钥解密后才能拿到正式的支付结果。密钥配置在云函数环境变量里(绝对不能写进代码仓库),验签通过的回调才允许更新订单。这把钥匙管不好,别人就能伪造“已付款”,等于你家收银台谁都能盖章。

配置/代码

先列出配置清单,缺一项联调就会卡住:

配置项 位置 用途
商户号 mchid 微信支付平台 标识你的商户身份
APIv3 密钥 微信支付平台 回调验签解密
商户证书与私钥 本地下载、云函数环境 请求签名
支付回调地址 微信支付平台 指向云函数 HTTP 触发
云函数 HTTP 触发路径 云开发控制台 供回调访问
appid 与 secret 小程序后台 调起支付凭证

核心代码第一段是 createOrder 里调统一下单(伪代码展示主线,证书加载等细节按官方 sdk 走):

// createOrder 云函数:统一下单
const res = await cloud.callContainer /* 或直接用支付 sdk */ ;
// 请求参数要点:out_trade_no 用订单号、amount 单位为分、
// notify_url 指向 payCallback 的 HTTP 触发地址
const payParams = {
  appid, mchid, description: '精品商城订单',
  out_trade_no: orderId,
  notify_url: 'https://<云函数域名>/payCallback',
  amount: { total: totalFen, currency: 'CNY' }
};
// 返回前端调起支付所需参数(timestamp、nonceStr、package 等)
return { paySign: signResult, orderId };

这里 amount 的单位是分,与我们在 2.1 节定的“价格用分”的规范天然一致,不用换算——又一个字段规范的红利。支付链路的联调技巧,在腾讯云实践手册里也有相关章节,配置项管理那部分很值得参考。

第二段是回调处理,验签是第一道关。回调报文里的关键字段含义先理清:out_trade_no 是我们自己的订单号,transaction_id 是微信那边的交易号,trade_state 是支付结果。两个单号都要存进订单文档,后续查交易、退款、客服核对都靠它们。

// payCallback 云函数骨架
const apiV3Key = process.env.APIV3_KEY; // 环境变量,勿入库
// 1. 解密回调报文,签名校验由 sdk 完成
const plain = decryptResource(req.body.resource, apiV3Key);
if (plain.trade_state !== 'SUCCESS') return { code: 'FAIL' };
// 2. 幂等检查:订单已处理过则直接返回成功
const order = await db.collection('orders').doc(plain.out_trade_no).get();
if (order.data.status === 'paid') return { code: 'SUCCESS' };
// 3. 校验金额一致再更新状态
if (order.data.totalFen !== plain.amount.total) {
  await logAndAlert('金额不一致', plain); return { code: 'FAIL' };
}
await db.collection('orders').doc(plain.out_trade_no)
  .update({ data: { status: 'paid', paidAt: Date.now() } });
return { code: 'SUCCESS' };

其中金额校验和幂等检查是最容易被漏掉的两步:只有交易号对应的金额和我们库里记录的一致,才允许更新成已付款——防止回调被篡改或串单。

踩坑

第一个坑是前面讲的回调白名单。排查回调问题的第一步:先看云函数日志里有没有请求记录,没有就是压根没进来,多半是域名没配对。

第二个坑是金额单位混乱。微信支付要分,有处代码把元直接传了过去,0.01 元的单子被传成 0.01 分,收银台报参数错误但没人看出来。统一定一个死规矩:项目内部所有金额一律用分,边界处需要换算的地方单独写一行注释提醒。

第三个坑是幂等。回调是重试机制,同一笔付款可能回调多次(网络抖动时尤其常见),如果第一次已经更新了订单状态、第二次又跑了一遍流程,库存会被重复扣、消息重复发。幂等检查就是第二段代码里的订单状态判断,必须放在最前面。我们的口诀是“先查订单旧状态,再动任何数据”,和 3.2 节库存回补的幂等逻辑同源。

第四个坑是前端相信了自己的支付结果。wx.requestPayment 的 success 并不等于资金到账,一定要以回调或查询订单状态为准。我们的做法是付款后轮询订单状态最多 10 秒,超时给“支付确认中”的中间态提示,让顾客安心。

第五个坑是证书更新导致全线支付失败。商户证书到期前一周就要在日历上标红,我们吃过暗亏:到期当天所有下单失败,而且报错信息绕了好几个弯子才定位到证书。给证书配个监控告警(5.2 节那套),到期前自动通知。

第六个坑是测试单混入真实流水。必须区分环境:dev 环境连测试商户号(微信支付提供沙箱能力),真实环境才用正式商户号,账号白名单里小额测试单自动打标,月底对账时一把过滤掉。如果条件允许,测试账号直接固定一两个openid,本地开发只在这两个账号上点支付,流水的清洁度会好很多。

小结

支付链路全部打通的那天,我盯着测试单的状态从“待付款”跳到“已付款”,感叹这条链路看着长,拆开来每一步都是“配置 + 一段代码 + 一个验签”的组合。经验层面就三句话:配置先列清单别凭记忆、验签是底线绝不让步、一切以回调为准前端只读。对了,金额单位、幂等、证书这三件事,每一件出问题都不是小事故。朋友后来跟我说,自从支付这条链路稳了,她才真正觉得自己“开了一家店”,之前老觉得是在玩乐高。付款成功之后订单还要走很长一段旅程(发货、收货、退款、关闭),那就是下一节的内容了。

延伸阅读