ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信JSAPI支付全链路实战:签名、调起、回调与幂等自检

微信JSAPI支付全链路实战:签名、调起、回调与幂等自检

简介:一套可配置运行的 Java 版微信 JSAPI 支付工程包,面向需要接入公众号支付的后端开发者与项目维护人员,重点解决统一下单、支付请求封装、签名校验、配置加载与支付结果处理等常见接入问题,适合已有一定 Java Web 基础的读者对照学习或二次改造。压缩包共 111 个文件,约 18.29MB,包含 44 个 jar 依赖、19 个 class 编译文件、16 个 xml 配置、11 个 java 源码以及 properties、jsp、js 等配套文件,既可查看依赖组成,也便于按源码与配置结构梳理支付调用链路。目前已有 4243 人学习下载,说明其在微信支付接入场景中具有一定参考价值。内容层面可见支付控制器、请求工具、配置实现、常量定义与 HTTP 通信封装,能帮助读者理解下单、查询与支付结果处理等环节的组织方式,并借助现成工程减少环境搭建与参数联调成本。

1. 从点下支付按钮说起:wechatpad.zip 兜住的是哪段链路

公众号 H5 里点“立即支付”,屏幕弹出确认框,用户按一下指纹,订单从待支付变成已支付——这条三秒的链路,后端要经历统一下单、签名、换 prepay_id、前端调起、异步回调验签、幂等落库六个环节。JSAPI 支付难的地方不在于接口数量,而在于每个环节都埋着隐式约束:参数要按字典序排、空值不能进签名、金额单位是分、openid 必须和 appid 同一主体。wechatpad.zip 就是把这些约束固化进代码的一份工程包,适合正在做公众号网页支付、又不想从零拼签名串的开发者,也适合已经接过一次但总在回调和对账上翻车的人。

2. 解包先看骨架:目录、依赖、配置三处对齐

2.1 目录结构决定你该改哪些文件

拿到压缩包之后,不要先运行命令。解压后做的第一件事是查看目录,确认这个包把哪些层封装成了工具、哪些层留给你接入业务。这类 JSAPI 支付工程包常见的组织方式如下:

路径职责是否需要改
config/appid、商户号、密钥、回调地址必改
lib/sign.*参数排序、拼接、摘要、转大写一般不动
lib/unifiedorder.*统一下单请求与 XML 解析少量改
lib/notify.*回调验签、报文解析少量改
demo/可运行的调起示例参考
cert/退款、撤销用的证书按需放

这个分包方式的好处是签名逻辑被抽成单独模块,你排查“签名错误”时只需要盯一个文件。我一般会先打开lib/sign.*,看它接收的参数是不是“已剔除空值的对象”,如果它把sign字段也一起参与排序,那基本可以确定这个包需要先修一处。紧接着看lib/unifiedorder.*里trade_type是写死还是从配置读取,写死成NATIVE的包不能直接用于公众号支付,得改成JSAPI并把openid传进去。

demo/目录不要跳过。它通常包含一份最小的前端调起页面,能让你在不接业务系统的情况下先跑通一次真实支付。我习惯先把 demo 的配置换成自己的测试商户参数,用一分钱下单验证链路,确认没问题再往业务代码里搬。这样出问题时你能确定是包的问题还是自己业务代码的问题。

还有一个容易忽略的点:看包里有没有logs/或日志工具。支付链路的排查高度依赖原始报文,下单前的参数、签名串、回调原文这三样如果没有落盘,出问题只能靠猜。

2.2 运行环境与依赖:先锁版本再装

这类包多数是 Node.js 写的,签名依赖语言自带的加密库,不额外引第三方加密包,这是好事。但下单请求可能依赖axios、request或原生https,不同包不一样。装依赖之前先看package.json,确认运行时版本要求。

# 解压到独立目录,避免和现有工程混在一起 unzip wechatpad.zip -d wechatpad cd wechatpad # 先看依赖清单和启动脚本,确认加密库是否为内置模块 cat package.json # 安装依赖,生产环境不要带 devDependencies npm install --production # 跑一遍 demo,确认基础环境没问题 node demo/server.js

第一段命令只是解压,注意不要解压到已有项目的根目录,配置文件名冲突时很难排查。第二段是看清单,重点确认engines字段和加密相关依赖,如果包用的是crypto-js而不是内置crypto,签名结果在编码环节容易出偏差,后面验签对不上时要优先怀疑这里。第三段安装时建议先备份一份package-lock.json,不要随手npm update,签名库的小版本升级改掉默认编码的事我见过不止一次。第四段启动 demo 后,用手机在公众号环境里打开对应页面,能弹出支付框就说明环境和配置基本对齐。

提示:Node 版本不要低于 14,低版本的crypto.randomBytes和URLSearchParams行为有差异,容易出现 nonce_str 长度不对的问题。

2.3 配置项:六个字段错一个就全盘失败

配置文件是这类包最容易出错的地方,因为错误不会在启动时报出来,而是在下单或调起时才暴露。下面这六项是 JSAPI 支付绕不开的:

配置项含义常见错误
appid公众号或应用唯一标识与 openid 来源不一致
mch_id商户号用了子商户号但没配子商户参数
api_key商户平台设置的密钥与签名算法不匹配、复制时带空格
notify_url异步通知地址内网地址、带登录态、非 80/443
trade_type交易类型写成 NATIVE 或 APP
cert_path证书路径退款时才用,但路径错会拖到很晚才发现

appid和 openid 的对应关系是最隐蔽的一处。用户在你公众号里授权拿到的 openid,只能用于这个公众号绑定的商户号下单,换成另一个 appid 的 openid 去下单,接口会返回“openid 错误”,但错误码不一定直观。配置里最好把 appid 和 openid 的获取来源写在同一处注释,后面接手的人能少走弯路。

api_key复制时带首尾空格是高频事故,签名串拼出来看着完全正常,MD5 结果却总是对不上。我一般会在加载配置后加一行长度校验,密钥长度不符合预期直接启动失败,比等到线上一笔笔报错强。

notify_url必须是公网可访问的地址,不能带登录态,也不能是内网 IP。很多人在本地开发时用内网穿透工具临时映射,上线时忘了改回正式域名,结果支付成功、订单一直待支付,查半天以为是回调验签写错了。

3. 统一下单与签名:prepay_id 之前的每一步都不能含糊

3.1 下单参数:哪些必填、哪些看着可选其实必填

统一下单是整条链路的起点,参数错一个,后面全断。JSAPI 场景下必须带的字段如下:

字段说明注意点
appid应用标识与 openid 同源
mch_id商户号与密钥配套
nonce_str随机串长度不超过 32,别用固定值
body商品描述不要含特殊字符,避免 XML 转义问题
out_trade_no商户订单号全局唯一,重复下单会报错
total_fee金额单位是分,1 元写 100
spbill_create_ip终端 IP取用户真实 IP,不要写 127.0.0.1
notify_url回调地址公网可达
trade_type交易类型固定 JSAPI
openid用户标识网页授权或静默授权获取

total_fee的单位是最经典的坑。前端传过来的是元,后端直接塞进去,用户付了 100 倍的钱,退款流程跟着一起炸。我一般会在下单函数入口做一次强制转换,并打印原始值和转换后的值,日志里能一眼看出来。

out_trade_no的唯一性要由业务侧保证,不能靠时间戳碰运气。同一订单重复发起支付时,正确做法是复用未支付的订单号重新下单,而不是生成新单号,否则对账时一笔业务会对应多条支付记录。

spbill_create_ip取的是用户客户端 IP。如果服务前面有反向代理,直接读req.connection.remoteAddress拿到的是代理地址,需要从X-Forwarded-For里取第一个非内网地址。这个字段填错不一定立刻报错,但风控判定会受影响。

3.2 签名串怎么拼:字典序、空值、&key=

签名规则本身不复杂,难在细节。参与签名的参数去掉sign本身,去掉空值,按参数名 ASCII 码从小到大排序,拼成k=v&k=v的形式,末尾追加&key=密钥,做 MD5 后转大写。

const crypto = require('crypto'); /** * 生成 JSAPI 支付 v2 签名 * @param {Object} params 参与签名的参数对象(不要传 sign 字段) * @param {String} apiKey 商户平台设置的 API 密钥 * @returns {String} 32 位大写签名 */ function buildSign(params, apiKey) { // 1. 过滤掉空值、undefined、null,以及 sign 字段本身 const keys = Object.keys(params) .filter(k => k !== 'sign') .filter(k => { const v = params[k]; return v !== '' && v !== undefined && v !== null; }) .sort(); // 2. 按参数名字典序排序 // 3. 拼成 k=v&k=v 形式,值不做 URL 编码 const raw = keys.map(k => `${k}=${params[k]}`).join('&'); // 4. 末尾追加 &key=API密钥,MD5 后转大写 return crypto.createHash('md5') .update(`${raw}&key=${apiKey}`, 'utf8') .digest('hex') .toUpperCase(); } module.exports = { buildSign };

第一个过滤动作去掉了sign,因为签名不能把自己算进去,很多包在这里漏了判断,导致二次签名时结果永远对不上。第二个过滤去掉空值,注意值为0的字段是有效值,不能用if (!v)判断,否则金额为 0 的测试单会出问题。排序用默认的sort()即可,参数名都是小写字母加下划线,ASCII 排序和字典序一致。

拼接时值不做 URL 编码,这一点和 v3 的签名规则不同,混用会得到完全不同的结果。最后转大写是 v2 的硬性要求,小写签名提交上去会直接返回签名错误。

如果包里用的是 HMAC-SHA256,把最后一步换成crypto.createHmac('sha256', apiKey).update(raw).digest('hex').toUpperCase(),同时sign_type要跟着改成HMAC-SHA256,两者不一致同样报签名错误。

3.3 发请求拿 prepay_id:超时、重试与落库

下单接口收的是 XML 报文,返回的也是 XML。包里如果有现成的 XML 构造和解析工具就直接用,没有的话自己包一层,注意 CDATA 的处理。

const axios = require('axios'); const crypto = require('crypto'); const { buildSign } = require('./sign'); const { appid, mchid, apiKey, notifyUrl } = require('../config'); /** * JSAPI 统一下单,返回 prepay_id * @param {Object} order 业务订单信息 */ async function createJsapiOrder({ openid, outTradeNo, totalFee, body, clientIp }) { const params = { appid, mch_id: mchid, nonce_str: crypto.randomBytes(16).toString('hex'), body: body.slice(0, 128), // 描述超长会被截断,提前处理 out_trade_no: outTradeNo, total_fee: totalFee, // 单位:分 spbill_create_ip: clientIp, notify_url: notifyUrl, trade_type: 'JSAPI', openid, }; params.sign = buildSign(params, apiKey); // 下单接口要求 text/xml,超时给 8 秒足够 const { data } = await axios.post( 'https://api.mch.weixin.qq.com/pay/unifiedorder', toXml(params), { headers: { 'Content-Type': 'text/xml' }, timeout: 8000 } ); const result = parseXml(data); if (result.return_code !== 'SUCCESS' || result.result_code !== 'SUCCESS') { // 把 err_code 和 err_code_des 一起抛出去,别只抛一句“下单失败” throw new Error(`${result.err_code}: ${result.err_code_des}`); } // prepay_id 有效期两小时,落库时记录创建时间 return result.prepay_id; }

nonce_str每次都要重新生成,复用同一个随机串不会报错,但会让日志失去区分度。body截断到 128 字符是保守做法,超过长度的描述在部分场景下会被截断,导致签名串和实际发送的不一致。超时设置成 8 秒,比默认值短,避免用户端等待过久;但不要设置自动重试,下单不是幂等操作,网络超时后应当用原订单号去查询接口确认状态,而不是再发一次下单请求。

返回结果里return_code表示通信是否成功,result_code表示业务是否成功,两层都要判断。只判其中一个,遇到“通信成功但业务失败”的情况会把空 prepay_id 传给前端,调起阶段才报错,排查范围被放大。

3.4 前端参数二次签名:大小写是重灾区

拿到 prepay_id 之后还没结束,前端调起需要再签一次名,参与字段是appId、timeStamp、nonceStr、package、signType。注意这里的appId是大写 I,和下单单时的appid不同,这是最容易翻车的一处。

/** * 生成前端调起支付所需的参数 * @param {String} prepayId 统一下单返回的预支付标识 */ function buildPayParams(prepayId, appid, apiKey) { const p = { appId: appid, // 注意大写 I timeStamp: String(Math.floor(Date.now() / 1000)), // 秒级,字符串 nonceStr: crypto.randomBytes(16).toString('hex'), package: `prepay_id=${prepayId}`, // 前缀不能少 signType: 'MD5', }; p.paySign = buildSign(p, apiKey); return p; }

timeStamp必须是字符串形式的秒级时间戳,传数字或毫秒值在部分客户端上会导致调起失败。package的值必须带prepay_id=前缀,只传裸的 prepay_id 是最常见的调起失败原因之一。签名时参与排序的字段名是appId,传给前端时字段名也是appId,两处保持一致即可,不要中途改成appid。

4. 前端调起支付:WeixinJSBridge 三个返回分支怎么处理

4.1 调起代码与字段映射

前端调起依赖WeixinJSBridge,这个对象在部分客户端里不是页面加载完就存在的,需要监听就绪事件。直接调用可能拿到undefined,表现就是点击支付没反应。

/** * 调起微信支付 * @param {Object} payParams 后端返回的二次签名参数 */ function invokePay(payParams) { if (typeof WeixinJSBridge === 'undefined') { // 老版本客户端需要等桥就绪 document.addEventListener('WeixinJSBridgeReady', function () { doInvoke(payParams); }, false); return; } doInvoke(payParams); } function doInvoke(payParams) { WeixinJSBridge.invoke('getBrandWCPayRequest', { appId: payParams.appId, timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType, paySign: payParams.paySign, }, function (res) { if (res.err_msg === 'get_brand_wcpay_request:ok') { // 前端成功只作为跳转依据,订单状态必须等后端回调确认 window.location.href = '/pay/result?orderNo=' + payParams.outTradeNo; } else if (res.err_msg === 'get_brand_wcpay_request:cancel') { // 用户主动取消,留在当前页即可 console.log('用户取消支付'); } else { // fail 分支,把 err_msg 上报,便于定位 reportError(res.err_msg); } }); }

六个字段一一对应后端返回的参数,少传一个都会失败。回调函数里的err_msg有三种取值,ok 表示支付成功,cancel 表示用户取消,其余都归到 fail。注意 ok 只代表客户端拿到的结果,不能据此直接发货,服务端回调才是最终依据。我见过有项目在前端 ok 分支里直接调发货接口,结果用户关掉页面、回调还没到达,货已经发了,后面又收到重复回调,库存直接对不上。

4.2 fail 的排查顺序

前端报 fail 时,先看err_msg的具体值,再按下面的顺序查:

第一步查支付授权目录。公众号后台配置的授权目录必须包含当前页面路径,路径精确到目录层级,多一层少一层都不行。目录改完后有缓存,短时间内可能仍不生效。

第二步查 openid 与 appid 是否同源。用 A 公众号拿的 openid 去 B 商户号下单,下单能成功,调起必失败。

第三步查 prepay_id 是否过期。有效期两小时,测试时把下单结果缓存下来反复用,过了两小时调起就失败,而日志里下单记录是成功的,很容易误判。

第四步查package格式。必须是prepay_id=xxx,少前缀、多空格都会失败。

第五步查时间戳。毫秒时间戳在某些安卓机型上直接失败,iOS 反而正常,这种平台差异最耗时间,统一用秒级字符串可以避开。

注意:同一个 prepay_id 只能调起一次,成功或失败后都作废,重试必须重新下单。

4.3 支付结果页的写法

支付结果页不要只读 URL 参数就显示“支付成功”,参数是前端拼的,可以被篡改。正确做法是结果页向后端查一次订单状态,后端返回已支付才展示成功,返回待支付就轮询几次,超过一定次数提示“支付结果确认中”。

轮询间隔不要固定,前三次间隔短一些,后面逐步拉长,总时长控制在十秒左右。微信回调通常在几秒内到达,但网络抖动时会延迟,轮询太久用户会以为卡死。

5. JSAPI 支付踩坑记录:五条现象、原因和改法

5.1 签名类

现象:下单接口返回“签名错误”,参数逐项核对都对。

原因:参与签名的对象里混入了空值字段,或者某个字段的值是数字而拼接时被隐式转成了科学计数法,还有一种情况是api_key末尾带了换行符,肉眼看不出来。

解决:在签名函数里打印排序后的原始串和最终摘要,和官方提供的签名校验工具逐字符比对。我一般会把原始串写进日志但脱敏密钥部分,出问题时直接翻日志,比重新复现快得多。

现象:下单成功,前端调起报“签名错误”。

原因:二次签名用了小写的appid字段名,或者signType传了MD5但签名用了 HMAC-SHA256。

解决:二次签名的字段名严格按appId、timeStamp、nonceStr、package、signType写,算法和signType保持一致。

5.2 调起类

现象:iOS 能正常调起,安卓一直失败,err_msg是 fail。

原因:时间戳用了毫秒值,或者package字段的值被前端做了 URL 编码,prepay_id=后面的等号变成了%3D。

解决:时间戳统一Math.floor(Date.now() / 1000).toString(),package原样传递,不要过任何编码函数。

现象:偶尔调起失败,重试又能成功。

原因:prepay_id 被缓存复用,或者下单和调起之间隔得太久。

解决:每次点击支付都重新下单,不复用旧 prepay_id;下单结果不要放进长期缓存。

5.3 回调类

现象:用户支付成功,订单状态一直不变,后端日志里没有回调记录。

原因:notify_url配的是内网地址或带鉴权的地址,微信服务器访问不到;或者回调地址返回了非 200 状态码,微信会按策略重试但不会无限重试。

解决:用公网地址,回调接口不做登录校验,处理完返回规定的 XML 报文,return_code和return_msg都要写。

现象:同一订单被重复发货。

原因:没做幂等,微信在没收到成功响应时会重复通知,间隔从十几秒到几分钟不等。

解决:回调入口先用订单号查状态,已处理过直接返回成功;状态更新用带条件的 SQL,update ... where status = '待支付',影响行数为 0 就说明已被处理。

现象:回调验签总是失败。

原因:解析 XML 时把 CDATA 去掉了,或者验签时把sign字段也纳入了排序。

解决:验签前剔除sign,其余字段按原值参与,CDATA 内容原样保留。

6. 上线前的支付自检:把链路跑成可验证的闭环

真金白银的支付链路,上线前我会固定走一遍自检,不靠“感觉没问题”。第一步用测试商户下一笔一分钱的真实订单,走完下单、调起、支付、回调、查单五个动作,确认每一步的日志都能对上。第二步故意制造一次失败:把 openid 换成别的公众号的,确认前端能拿到 fail 且后端没有误判成功。第三步模拟重复回调,手动向回调地址连发三次相同报文,看订单状态是否只变一次、发货记录是否只有一条。

日志埋点是这一步的核心。下单前打印完整参数(密钥脱敏)、签名前的原始串、回调收到的原文,这三段日志在排查时能省掉大量沟通成本。我习惯给每笔订单生成一个链路 ID,从下单到回调全程带上,查问题时用这个 ID 一次捞出全部记录,不用在多个服务之间来回拼时间线。

自检项通过标准失败时的第一嫌疑
下单返回有 prepay_id 且 result_code 为 SUCCESS签名或 openid
前端调起能弹出支付确认框授权目录或 package 格式
支付回调五分钟内收到且验签通过notify_url 可达性
重复回调订单状态只变一次幂等条件没加
金额核对数据库金额与支付记录一致分元转换

对账不要等到财务来找。每天跑一次查单接口,把本地订单和支付平台记录比对,差异单单独列出来人工处理。金额、订单号、状态三项对不上就标红,比事后翻几个月的流水轻松得多。

从那以后我每次接新的支付通道,都强制先把签名串原文打出来存一份日志,再跑一遍重复回调的模拟,两件事做完才允许提测。这个习惯救过我两次,一次是密钥多了个空格,一次是幂等条件写漏了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进