简介:面向需要接入微信 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_id=xxx,这个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 | 自己填,必须公网可达 | 前端显示成功,订单永远UNPAID |
spbillCreateIp | 服务器出口 IP | 部分场景返回IP 非法 |
| 证书与序列号 | 商户平台下载 | APIv3 报签名验证失败 |
apiKey是 32 位字符串,不是 16 位也不是 64 位,多一个空格都算错。粘进.env之后建议打印一次长度确认,这个动作我每次都做,因为肉眼分不清l和1。
APIv2 用 MD5 或 HMAC-SHA256,APIv3 换成 RSA,signType也从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)或 Base64(v3); - 手机微信里打开页面,收银台能弹出来,说明授权目录对;
- 付款后微信侧有扣款记录,说明金额和商户号对;
- 服务端日志里能看到回调进入,并且返回了
SUCCESS; - 数据库订单状态从
UNPAID变成PAID,transaction_id有值。
第 5 步失败但第 4 步成功,八成是notifyUrl不通或者返回体格式不对。第 6 步失败但第 5 步成功,八成是幂等 SQL 的WHERE条件写错了,affectedRows是 0。分清楚这两类,排查方向完全不同。
4. 核心代码拆解:签名、调起、回调三处最容易翻车的地方
4.1 后端统一下单与 paySign:待签串怎么拼
签名这块只有一条规则:把所有非空参数按 key 的字典序排好,拼成k=v&k=v的形式,末尾追加&key=API密钥,再做哈希。听着简单,实际错的地方集中在「哪些参数参与」和「空值算不算」。
// 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更不能,一旦拼出attach=undefined这种东西,签名必错。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不能写成appid,timeStamp不能写成timestamp,nonceStr里的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就是判断依据,比先查后改安全得多。
应答体必须是<xml><return_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-8,MD5 结果自然对不上。解决:把待签串原文打印出来,和手动拼的一行一行对。中文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 status='UNPAID'的条件更新,用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上挂?tenant=xxx,进路由先按参数取配置再验签,注意这个参数不要参与签名。二是幂等表要加上租户维度,out_trade_no的唯一索引改成(tenant_id, out_trade_no)联合唯一,否则两个租户撞单号会互相覆盖。三是金额校验必须用租户自己的订单表查,别图省事走一个全局表。
| 方法 | 输入 | 输出 | 幂等要求 |
|---|---|---|---|
createPayment | 租户上下文、订单号、金额(分)、openid | 前端调起参数 | 同单号重复调用返回同一prepay_id |
queryPayment | 租户上下文、订单号 | 统一状态枚举 | 无副作用 |
verifyNotify | 租户上下文、原始报文 | 校验结果 + 订单号 | 必须可重复调用 |
验证方式我一般分两层:先用一个假的渠道实现,createPayment直接返回固定参数、verifyNotify永远通过,把业务侧的状态机和发货逻辑跑一遍单元测试;再拿真实商户号跑 1 分钱,重点看回调进来时租户有没有取对、affectedRows是不是 1。第一层能挡住 90% 的改造错误,第二层只验证配置。
踩过一次坑:改造完只测了主租户,第二个租户上线当天所有订单都挂到主租户账下,原因就是notifyUrl里的tenant参数被签名逻辑当成了业务参数,验签一直失败,回调全进了重试队列。从那以后我每次加渠道或者加租户,都强制先把「回调地址 + 租户解析 + 幂等索引」这三项对着清单过一遍,再动别的。希望这套拆法能帮到你,少走一段我走过的弯路。
本文还有配套的精品资源,点击获取