资讯详情

资讯详情

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

简介面向需要接入微信 JSAPI 支付的 Java 开发者这份可运行的示例工程围绕公众号内调起支付这一典型场景提供从参数配置到发起请求的完整参考实现适合已具备一定 Java Web 基础、希望快速跑通支付链路并排查签名、参数与回调问题的人员使用。压缩包共 111 个文件约 18.29MB以 44 个 jar 依赖、19 个 class 与 11 个 java 源码为主体辅以 16 个 xml 配置、4 个 properties 参数文件及 3 个 jsp 页面工程结构较完整可直接部署测试。代码涵盖支付工具类、请求封装、配置实现、控制器示例与常量定义等模块便于读者理解统一下单、签名生成、回调处理等关键环节的组织方式并对照自身项目调整商户号、证书、域名与支付目录等配置。已有 4243 人学习下载可作为接入前的参考样例、联调对照与排错思路来源帮助减少从零搭建调试环境的时间。1. 从「点了没反应」说起wechatpad 里打包了 JSAPI 支付的哪些环节公众号 H5 里点一下「立即支付」转圈两秒控制台一行WeixinJSBridge is not defined或者更难受的收银台弹出来了钱也扣了库里订单还是UNPAID。JSAPI 支付翻车点几乎都集中在这三处——调起、签名、回调。wechatpad.zip 打包的就是把这条链路一次走通的骨架工程后端统一下单并算 paySign前端在微信内置浏览器里拉起收银台异步回调回来验签、校验金额、幂等落库再补一个轮询兜底。它适合正在给公众号、H5 商城或会员页接收银台的开发者当脚手架用。拿它对着接口文档从零试错光是待签字符串的排序就能耗掉半天先跑通一遍完整流程再回头理解每个参数为什么这么填效率差得很远。2. wechatpad 的结构与选型为什么 JSAPI 支付非要后端签名不可2.1 解压后的目录四块拼图各管什么这类工程解压下来基本是前后端两段式目录不会太深但每一层都有明确分工。常见做法是server放签名和落库web放页面与调起逻辑sql放建表脚本根目录留一份 README 说明配置项。第一次读不要从入口文件开始按「下单 → 调起 → 回调」的顺序翻会顺很多。wechatpad/ ├── server/ │ ├── app.js # 服务入口挂路由、静态目录 │ ├── config/pay.config.js # appId / mchId / apiKey / notifyUrl │ ├── service/wxpay.js # 签名、统一下单、查单、验签 │ ├── routes/order.js # 下单、查状态 │ ├── routes/notify.js # 支付结果回调 │ └── cert/ # APIv3 用的商户证书与私钥 ├── web/ │ ├── index.html # 收银台页面 │ ├── js/bridge.js # WeixinJSBridge 封装 │ └── js/pay.js # 业务侧调起与轮询 ├── sql/order.sql # 订单表 唯一索引 └── README.md目录/文件职责改它的频率config/pay.config.js商户号、密钥、回调地址部署时必改service/wxpay.js拼串签名、请求微信、解析 XML几乎不动routes/notify.js验签、金额校验、幂等更新接业务时改web/js/pay.js调起收银台、结果确认接业务时改sql/order.sql订单表结构加字段时改后端如果是 Java 或 PHP把wxpay.js里那几个纯函数按同样逻辑翻一遍即可签名算法是语言无关的难点不在语法在于参数集合有没有多一个少一个。2.2 选型判断前端调起 后端签名 回调落库的三段式有个常见误解既然收银台是前端拉起来的那签名也放前端算省一次请求。这个做法在 JSAPI 支付里走不通原因是paySign要用商户 API 密钥做 HMAC 或 MD5密钥一旦下发到浏览器等于把商户号的收款权限交出去了。所以合格的结构一定是三段后端拿密钥算签名并下单前端只拿五个字段去调起回调由后端独立处理。第二段里后端返回给前端的其实只有五个字段appId、timeStamp、nonceStr、package、signType外加一个paySign。package的值固定是prepay_idxxx这个prepay_id必须由后端调统一下单拿到前端伪造不了。只要把「密钥不出后端」这条守住剩下的都是工程细节。第三段最容易被低估。收银台返回get_brand_wcpay_request:ok只代表微信客户端受理了这笔支付不代表你的服务器已经知道钱到了。真正的到账凭据是微信推到你notify_url的那条异步通知前端那个ok只能拿来做「去查一下结果」的触发信号。把这三段分清楚代码结构自然就出来了。提示如果项目里出现了前端直接拼paySign的代码先别急着跑先确认密钥是怎么下发的——这属于设计问题不是配置问题。2.3 一条支付链路的六个环节与各自的失败点把整条链路摊开无非六个动作每个动作对应一类典型报错前端请求后端下单后端生成out_trade_no并落一条UNPAID订单后端调统一下单拿回prepay_id失败通常是参数缺失或 IP 不对后端算paySign返回给前端失败通常是待签串拼错前端调WeixinJSBridge.invoke失败通常是授权目录、prepay_id过期、openid不匹配用户付款微信异步回调notify_url失败通常是返回内容格式不对导致重推后端验签、校验金额、更新状态、触发发货失败通常是幂等没做。六个环节里第 2 到第 4 步的报错信息都很难自己解释清楚微信只会给你一个签名错误或fail到底是哪个字段错了它不说。所以下面跑通流程时每一步都要能单独验证别一股脑端到端测出了错根本定位不了。3. 把它跑起来环境、配置项与 1 分钱的首单验证3.1 依赖与启动三条命令把前后端拉起来前端是原生页面不需要打包工具后端是个轻量 Node 服务依赖只有 express 和 mysql 驱动这个量级。常见做法是把配置抽到.env避免密钥进版本库。启动顺序是先建表再起服务最后用微信内置浏览器打开页面。unzip wechatpad.zip cd wechatpad # 1) 建表订单表必须带 out_trade_no 唯一索引这是后面幂等的底座 mysql -u root -p your_db sql/order.sql # 2) 装依赖并写配置 cd server npm install cp .env.example .env # 然后按 3.2 逐个填 # 3) 起服务静态目录指向 ../web npm run dev # 默认监听 3000页面在 http://你的域名:3000/npm run dev起来之后先用浏览器直接访问一次首页确认静态资源能加载、/api/order能返回 JSON。这一步不涉及支付纯粹验证服务通了。很多人跳过这一步直接上手机测结果页面白屏还以为是 JSSDK 的问题白白浪费半小时。数据库连接串同样放在.env里连接池大小给 5 到 10 就够支付回调是低频事件不需要大池子。如果暂时不想装 MySQL把db那层换成本地文件存储也能先跑通链路接口形状不变。3.2 配置项逐个过漏一个就是签名错误配置是这套代码里最容易出错的地方因为错了不报具体字段。下面这张表按「错了会怎样」倒着记比按顺序背有用。配置项从哪拿填错的典型症状appId公众号后台调起时提示 appId 与 openid 不匹配mchId商户平台统一下单返回mch_id 参数错误apiKey商户平台 API 安全设置一律签名错误且回调也验不过notifyUrl自己填必须公网可达前端显示成功订单永远UNPAIDspbillCreateIp服务器出口 IP部分场景返回IP 非法证书与序列号商户平台下载APIv3 报签名验证失败apiKey是 32 位字符串不是 16 位也不是 64 位多一个空格都算错。粘进.env之后建议打印一次长度确认这个动作我每次都做因为肉眼分不清l和1。APIv2 用 MD5 或 HMAC-SHA256APIv3 换成 RSAsignType也从MD5变成RSA私钥签名用 SHA256withRSA。两套不要混用v3 的下单接口用 RSA 签名但返回给前端调起时那五个字段仍然要单独签一次签法跟着signType走。3.3 授权目录与测试号本地为什么调不起来本地localhost永远调不起 JSAPI 支付这不是代码问题。调起收银台的页面必须落在公众号后台配置的「支付授权目录」下面而授权目录只接受已备案的域名IP 和 localhost 都不行。合格的做法是把开发流程切成两段本地只验证「下单 签名」这段用 curl 打自己的接口看返回的paySign和prepay_id是否正常调起和回调放到一台有域名的测试机上验证。测试机上把notifyUrl配成公网地址微信才推得进来。还有一个高频误解以为用了测试号就能测支付。测试号能拿到openid但收银台拉不起来这是账户能力问题不是配置问题。真要端到端测用真实商户号商品金额设 1 分测完再改回来。注意openid必须和appId同属一个公众号从别的应用拿来的openid一律调起失败且报错信息不会告诉你原因。3.4 第一笔支付的验证清单首单不要直接上真实金额按下面顺序逐项确认每项都能独立判定后端日志里能看到统一下单的请求参数和返回的prepay_id接口返回的paySign是 32 位大写v2或 Base64v3手机微信里打开页面收银台能弹出来说明授权目录对付款后微信侧有扣款记录说明金额和商户号对服务端日志里能看到回调进入并且返回了SUCCESS数据库订单状态从UNPAID变成PAIDtransaction_id有值。第 5 步失败但第 4 步成功八成是notifyUrl不通或者返回体格式不对。第 6 步失败但第 5 步成功八成是幂等 SQL 的WHERE条件写错了affectedRows是 0。分清楚这两类排查方向完全不同。4. 核心代码拆解签名、调起、回调三处最容易翻车的地方4.1 后端统一下单与 paySign待签串怎么拼签名这块只有一条规则把所有非空参数按 key 的字典序排好拼成kvkv的形式末尾追加keyAPI密钥再做哈希。听着简单实际错的地方集中在「哪些参数参与」和「空值算不算」。// server/service/wxpay.js const crypto require(crypto); const https require(https); const config require(../config/pay.config); // 32 位随机串下单、调起、回调三处都要用同一笔单内不要复用 function nonceStr(len 32) { return crypto.randomBytes(16).toString(hex).slice(0, len); } // 待签串剔除空值、剔除 sign 本身按字典序拼接 function buildSignStr(params, apiKey) { const keys Object.keys(params) .filter(k k ! sign params[k] ! params[k] ! null) .sort(); return keys.map(k ${k}${params[k]}).join() key${apiKey}; } function md5Upper(str) { return crypto.createHash(md5).update(str, utf8).digest(hex).toUpperCase(); } // 统一下单拿到 prepay_id async function unifiedOrder({ openid, outTradeNo, totalFee, body, notifyUrl }) { const params { appid: config.appId, mch_id: config.mchId, nonce_str: nonceStr(), body, // 商品描述会显示在用户账单上别写测试字样 out_trade_no: outTradeNo, // 32 字符内自己保证唯一 total_fee: totalFee, // 单位是分写成元是最常见的翻车点 spbill_create_ip: config.spbillCreateIp, notify_url: notifyUrl, trade_type: JSAPI, openid, // JSAPI 必传且必须是本公众号下的 openid }; params.sign md5Upper(buildSignStr(params, config.apiKey)); const ret await postXml(https://api.mch.weixin.qq.com/pay/unifiedorder, toXml(params)); if (ret.return_code ! SUCCESS || ret.result_code ! SUCCESS) { throw new Error([${ret.err_code}] ${ret.err_code_des || ret.return_msg}); } return ret.prepay_id; } // 返回给前端调起的六个字段多一个少一个都拉不起来 function buildPayParams(prepayId) { const p { appId: config.appId, timeStamp: String(Math.floor(Date.now() / 1000)), // 秒级字符串 nonceStr: nonceStr(), package: prepay_id${prepayId}, signType: MD5, }; p.paySign md5Upper(buildSignStr(p, config.apiKey)); return p; }buildSignStr里那个filter是整段的核心。空字符串不能参与签名undefined更不能一旦拼出attachundefined这种东西签名必错。buildPayParams的待签串和下单的待签串是两套参数集不要图省事用一个函数塞两遍paySign不参与自己的签名package要带prepay_id前缀。postXml是个普通的 HTTPS 请求封装记得设超时默认行为是无限等网络抖动时会把你的下单接口一起拖死。解析 XML 后先看return_code再看result_code前者是通信层后者是业务层混着看会误判。4.2 前端 WeixinJSBridge 调起ready 事件与 err_msg 判断前端这段的坑不在代码量在时机。WeixinJSBridge不是页面一加载就存在的得等它注入完成否则直接invoke报 not defined。标准做法是监听WeixinJSBridgeReady事件已经存在就直接调。// web/js/bridge.js function invokeBridge(payParams) { return new Promise((resolve, reject) { const run () { WeixinJSBridge.invoke(getBrandWCPayRequest, payParams, res { const msg res.err_msg; // 只有这一个字段可信 if (msg get_brand_wcpay_request:ok) return resolve(res); if (msg get_brand_wcpay_request:cancel) return reject({ type: cancel, res }); reject({ type: fail, res }); }); }; // 未注入时排队等待这个事件只会触发一次 if (typeof WeixinJSBridge undefined) { document.addEventListener(WeixinJSBridgeReady, run, false); } else { run(); } }); }err_msg只有三个取值值得分支ok、cancel、fail。用户主动取消要单独处理别一律弹「支付失败」否则用户会以为钱扣了。fail里能拿到的信息极其有限get_brand_wcpay_request:fail就是一句「失败了」具体原因只能靠后端日志和授权目录配置去对。还有一个细节payParams里的字段名大小写敏感appId不能写成appidtimeStamp不能写成timestampnonceStr里的S是大写。这仨写错一个表现都是拉不起来而且不报具体字段。4.3 回调验签与幂等落库一次重试能不能把订单写两遍回调是最需要防御性编程的地方。微信在没收到SUCCESS应答时会按策略重复推送同一笔订单可能进来三次五次。如果你的处理逻辑是「收到就发货」用户就能收到多份。// server/routes/notify.js router.post(/wxpay/notify, express.text({ type: */*, limit: 1mb }), // 必须是 raw text不能是 json() async (req, res) { let data; try { data fromXml(req.body); } catch (e) { return res.send(xmlFail(bad xml)); } // 1) 验签回调的 sign 是微信算的算法和下单时完全一致 const sign data.sign; delete data.sign; if (md5Upper(buildSignStr(data, config.apiKey)) ! sign) { return res.send(xmlFail(sign error)); } // 2) 非成功通知直接应答避免无意义的重复推送 if (data.return_code ! SUCCESS || data.result_code ! SUCCESS) { return res.send(xmlOk()); } // 3) 金额必须回查本地订单不能信回调里的 total_fee const order await db.findOne( SELECT amount, status FROM orders WHERE out_trade_no ?, [data.out_trade_no]); if (!order || Number(order.amount) ! Number(data.total_fee)) { return res.send(xmlFail(amount mismatch)); } // 4) 幂等核心带状态条件的 UPDATE并发重推也只有一次能命中 const r await db.execute( UPDATE orders SET status PAID, transaction_id ?, paid_at NOW() WHERE out_trade_no ? AND status UNPAID, [data.transaction_id, data.out_trade_no]); if (r.affectedRows 0) return res.send(xmlOk()); // 已处理过照样回 SUCCESS await onPaid(data.out_trade_no); // 发货、开会员等业务动作放这里 res.send(xmlOk()); });三处设计值得单独说。express.text这一层很多框架默认把 body 解析成对象原始 XML 就没了验签必然失败所以这条路由必须挂在全局json()之前或者单独指定解析器。金额校验这一层回调查的是本地库里的amount不是回调说了算顺手把「1 分钱买 1000 块商品」这类篡改堵掉。状态条件更新这一层WHERE status UNPAID加上out_trade_no的唯一索引重复推送天然被吃掉affectedRows就是判断依据比先查后改安全得多。应答体必须是xmlreturn_code![CDATA[SUCCESS]]/return_code/xml这个形状少CDATA或者返回 JSON微信都认为你没收到继续重推。4.4 结果确认库里的状态才是唯一真相前端拿到ok之后不能直接跳「支付成功」页得回头问服务端。同时服务端也不能只等回调回调丢了网络抖动、服务重启订单就永远挂着。常见做法是双保险前端轮询几次服务端查状态时如果还是UNPAID就主动查一次单。// web/js/pay.js async function pollOrder(outTradeNo, times 5, gap 1200) { for (let i 0; i times; i) { const r await fetch(/api/order/${outTradeNo}/status).then(r r.json()); if (r.data r.data.status PAID) return true; await new Promise(s setTimeout(s, gap * (i 1))); // 退避别一秒一次猛打 } return false; }// server/routes/order.js查状态时顺手兜底 router.get(/order/:no/status, async (req, res) { const no req.params.no; let order await db.findOne(SELECT status FROM orders WHERE out_trade_no ?, [no]); if (order order.status UNPAID) { const r await orderQuery(no); // 主动查单 if (r.trade_state SUCCESS) { // 复用回调那套幂等更新两条路径写同一张表不会打架 order await markPaidIfUnpaid(no, r.transaction_id, r.total_fee); } } res.json({ code: 0, data: order }); });退避时间是刻意设计成递增的因为支付到账有延迟前 1 秒查三次基本都是UNPAID白打请求。轮询五次大约覆盖 18 秒超出就提示「支付结果确认中」让用户从订单列表再进来看比死等体验好。orderQuery和回调共用同一个幂等更新函数是这个设计里最关键的一点两条路径可能同时到达写同一张表时必须走同一套条件更新否则迟早出并发问题。5. 常见问题排查五个我真踩过的坑5.1 签名与配置类报错 -1 和 fail 的一半原因在这里现象统一下单接口返回签名错误或者前端调起直接fail后端日志里参数看着都对。原因待签串里混进了空值或undefined或者apiKey带了不可见字符。还有个隐蔽的body字段填了中文编码不是 UTF-8MD5 结果自然对不上。解决把待签串原文打印出来和手动拼的一行一行对。中文body统一Buffer.from(body, utf8)处理apiKey打印长度确认是 32。现象appId和openid不匹配但openid明明是从本公众号拿的。原因公众号的appId和商户号绑定的appId不是同一个或者中途换过公众号但配置没同步。多应用共用一个商户号时特别容易出这个。解决去商户平台确认「APPID 授权关系」配置里的appId必须和授权列表里的一致openid的来源也要对得上。现象金额总是差 100 倍。原因total_fee单位是分页面上显示的是元中间漏了乘 100或者反过来。解决入参层统一做一次Math.round(yuan * 100)存储层只存分展示层再除回去别在中间层来回转。5.2 调起与回调类前端说成功了库里还是 UNPAID现象用户付款成功微信也有扣款记录但订单一直是UNPAID回调日志一条没有。原因notifyUrl不通。常见的是端口没开、HTTPS 证书链不完整、或者 URL 后面带了参数被框架吃掉。还有一种回调进来了但返回的不是合法 XML微信判定失败后重推几次就放弃了。解决先用工具从公网 POST 一次你的回调地址看返回体确认应答是return_code为SUCCESS的 XML且带CDATA。证书链用完整的别只配叶子证书。现象同一笔订单发货逻辑执行了两次。原因回调重推 先查后改的写法两个请求都查到了UNPAID然后都更新了。解决改成UPDATE ... WHERE statusUNPAID的条件更新用affectedRows判断是否首次命中业务动作只在那之后再执行。数据库层面再加out_trade_no唯一索引兜底。6. 二次开发把它改成多商户共用的一套支付中间层单商户跑通之后下一步往往是「同一套系统里给不同业务线收款」。这时候最省事的改法不是复制一份wxpay.js而是把渠道差异收敛成三个方法createPayment、queryPayment、verifyNotify。上层业务只认这三个签名底下是微信、支付宝还是别的业务代码不关心。// server/service/pay-factory.js const channels { wxpay: { // 入参统一{ tenantId, outTradeNo, amount(分), openid } createPayment: (ctx, order) wxpay.unifiedOrder({ ...order, ...ctx }), queryPayment: (ctx, outTradeNo) wxpay.orderQuery(outTradeNo, ctx), verifyNotify: (ctx, rawBody) wxpay.verifyNotify(rawBody, ctx), }, }; // 按租户取配置配置里再按渠道分身新增商户只是加一条记录 async function getChannel(tenantId, channel wxpay) { const ctx await configRepo.findByTenant(tenantId); if (!ctx || !channels[channel]) throw new Error(channel not configured); return channels[channel].bind(null, ctx); }改造时有三个点必须一起动漏一个就会串号。一是回调地址要能区分租户常见做法是在notifyUrl上挂?tenantxxx进路由先按参数取配置再验签注意这个参数不要参与签名。二是幂等表要加上租户维度out_trade_no的唯一索引改成(tenant_id, out_trade_no)联合唯一否则两个租户撞单号会互相覆盖。三是金额校验必须用租户自己的订单表查别图省事走一个全局表。方法输入输出幂等要求createPayment租户上下文、订单号、金额分、openid前端调起参数同单号重复调用返回同一prepay_idqueryPayment租户上下文、订单号统一状态枚举无副作用verifyNotify租户上下文、原始报文校验结果 订单号必须可重复调用验证方式我一般分两层先用一个假的渠道实现createPayment直接返回固定参数、verifyNotify永远通过把业务侧的状态机和发货逻辑跑一遍单元测试再拿真实商户号跑 1 分钱重点看回调进来时租户有没有取对、affectedRows是不是 1。第一层能挡住 90% 的改造错误第二层只验证配置。踩过一次坑改造完只测了主租户第二个租户上线当天所有订单都挂到主租户账下原因就是notifyUrl里的tenant参数被签名逻辑当成了业务参数验签一直失败回调全进了重试队列。从那以后我每次加渠道或者加租户都强制先把「回调地址 租户解析 幂等索引」这三项对着清单过一遍再动别的。希望这套拆法能帮到你少走一段我走过的弯路。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →