资讯详情 微信JSAPI支付全链路实战:签名、调起、回调与幂等自检
📅 2026/10/30 14:40:11
简介一套可配置运行的 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/443trade_type交易类型写成 NATIVE 或 APPcert_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 元写 100spbill_create_ip终端 IP取用户真实 IP不要写 127.0.0.1notify_url回调地址公网可达trade_type交易类型固定 JSAPIopenid用户标识网页授权或静默授权获取total_fee的单位是最经典的坑。前端传过来的是元后端直接塞进去用户付了 100 倍的钱退款流程跟着一起炸。我一般会在下单函数入口做一次强制转换并打印原始值和转换后的值日志里能一眼看出来。out_trade_no的唯一性要由业务侧保证不能靠时间戳碰运气。同一订单重复发起支付时正确做法是复用未支付的订单号重新下单而不是生成新单号否则对账时一笔业务会对应多条支付记录。spbill_create_ip取的是用户客户端 IP。如果服务前面有反向代理直接读req.connection.remoteAddress拿到的是代理地址需要从X-Forwarded-For里取第一个非内网地址。这个字段填错不一定立刻报错但风控判定会受影响。3.2 签名串怎么拼字典序、空值、key签名规则本身不复杂难在细节。参与签名的参数去掉sign本身去掉空值按参数名 ASCII 码从小到大排序拼成kvkv的形式末尾追加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. 拼成 kvkv 形式值不做 URL 编码 const raw keys.map(k ${k}${params[k]}).join(); // 4. 末尾追加 keyAPI密钥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_idxxx少前缀、多空格都会失败。第五步查时间戳。毫秒时间戳在某些安卓机型上直接失败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都要写。现象同一订单被重复发货。原因没做幂等微信在没收到成功响应时会重复通知间隔从十几秒到几分钟不等。解决回调入口先用订单号查状态已处理过直接返回成功状态更新用带条件的 SQLupdate ... where status 待支付影响行数为 0 就说明已被处理。现象回调验签总是失败。原因解析 XML 时把 CDATA 去掉了或者验签时把sign字段也纳入了排序。解决验签前剔除sign其余字段按原值参与CDATA 内容原样保留。6. 上线前的支付自检把链路跑成可验证的闭环真金白银的支付链路上线前我会固定走一遍自检不靠“感觉没问题”。第一步用测试商户下一笔一分钱的真实订单走完下单、调起、支付、回调、查单五个动作确认每一步的日志都能对上。第二步故意制造一次失败把 openid 换成别的公众号的确认前端能拿到 fail 且后端没有误判成功。第三步模拟重复回调手动向回调地址连发三次相同报文看订单状态是否只变一次、发货记录是否只有一条。日志埋点是这一步的核心。下单前打印完整参数密钥脱敏、签名前的原始串、回调收到的原文这三段日志在排查时能省掉大量沟通成本。我习惯给每笔订单生成一个链路 ID从下单到回调全程带上查问题时用这个 ID 一次捞出全部记录不用在多个服务之间来回拼时间线。自检项通过标准失败时的第一嫌疑下单返回有 prepay_id 且 result_code 为 SUCCESS签名或 openid前端调起能弹出支付确认框授权目录或 package 格式支付回调五分钟内收到且验签通过notify_url 可达性重复回调订单状态只变一次幂等条件没加金额核对数据库金额与支付记录一致分元转换对账不要等到财务来找。每天跑一次查单接口把本地订单和支付平台记录比对差异单单独列出来人工处理。金额、订单号、状态三项对不上就标红比事后翻几个月的流水轻松得多。从那以后我每次接新的支付通道都强制先把签名串原文打出来存一份日志再跑一遍重复回调的模拟两件事做完才允许提测。这个习惯救过我两次一次是密钥多了个空格一次是幂等条件写漏了。希望帮到你。本文还有配套的精品资源点击获取