简介:这份资源是一套可运行的 Java 版微信 JSAPI 支付实现包,面向需要在网页端或公众号内接入微信支付的后端开发者、课程设计者及中小项目团队,用于解决统一下单、支付签名、发起支付请求、上报支付结果与配置管理等基础环节的落地问题;放入自己的商户号、证书与回调地址等参数后即可联调,适合已经掌握 Java Web 基础、希望快速熟悉支付对接流程的中级开发者。压缩包共 111 个文件,约 18.29MB,以 44 个 jar 依赖包、19 个 class 编译产物、16 个 xml 配置与映射文件、11 个 java 源码以及少量 properties 配置、jsp 页面与 js 脚本为主,既有可直接引用的支付工具类,也保留了源码,便于按需阅读和二次修改。已有 4243 人学习下载。借助其中的支付请求封装、签名与校验工具、结果上报实现和配置示例,读者能较快理解 JSAPI 支付的调用链路与参数组织方式,减少自行从零封装时在签名和配置上反复试错的成本。
1. 装 JSAPI 支付前,先认清 wechatpad.zip 到底替你把哪些活干了
上周帮人看一台新机器上的支付故障,商户号、密钥、证书一个字符没动,前端拉起 JSAPI 支付直接弹「签名错误」。折腾两个小时才发现,机器上的 Node 从 16 换到 20,运行时的 OpenSSL 也跟着换了代,签名串里一个不可见字符的处理行为变了,商户参数全对,签名就是过不去。这种坑不装一遍根本想不到。
JSAPI 支付在公众号、小程序场景里几乎是标配,接的人多,装翻的也多。wechatpad.zip 这套包把签名组装、参数校验、回调验签这几段最容易写错的部分收拢到了一起,但收拢不等于免配,运行时版本、证书路径、授权目录、回调地址仍然要一项项落地。装不好就是白屏、签名失败、回调收不到三连。这篇写给正在接支付的前后端和运维,从版本选型一路走到装完能跑通。
2. 环境与版本清单:JSAPI 支付依赖的运行时到底怎么选
动手解压之前先把运行时定下来,这一步定错,后面所有排查都是白费。JSAPI 支付这条链上真正吃版本的只有三个环节:后端运行时的加密库、HTTP 客户端的 TLS 能力、以及处理证书文件的 OpenSSL。很多人只盯着语言版本,忽略加密库的代际差异,最后卡在「本地能签、线上签不过」。
2.1 运行时版本对照与取舍
我把这几年实际跑过的组合整理成一张表,按「稳」到「新」排。表里的版本号是运行时本身的通用版本,不是这个包绑定的版本,包内实际依赖以解压后的依赖清单为准。
| 运行时 | 推荐版本 | 加密库 | 适配情况 | 备注 |
|---|---|---|---|---|
| Node.js | 18 LTS | OpenSSL 3.x | 稳 | 长期支持,生态兼容性最好 |
| Node.js | 20 LTS | OpenSSL 3.x | 稳 | 新项目首选,注意旧算法默认禁用 |
| Node.js | 16 | OpenSSL 1.1.1 | 可用但不建议 | 已停止维护,新机器别再用 |
| PHP | 8.1 / 8.2 | OpenSSL 3.x | 稳 | 扩展需开 openssl、curl、json |
| PHP | 7.4 | OpenSSL 1.1.1 | 勉强可用 | 部分新语法不兼容,谨慎 |
| Java | 17 | 自带 | 稳 | JDK 8 也能跑,但 TLS 默认版本偏低 |
我一般会优先选 Node 18 LTS。原因不是它新,而是它的生命周期够长,装一次能躺两年不用动。选 Node 20 也没问题,但要有心理准备:OpenSSL 3 默认把一批老算法挪进了 legacy provider,如果你的证书是老格式或者商户侧还在用偏旧的签名方式,会直接抛「unsupported」类错误,看起来像证书坏了,其实是算法被禁用。
PHP 这条线要额外注意扩展。装完 PHP 先跑一条检查,缺扩展的话后面调签名函数会直接 fatal error:
# 检查 JSAPI 支付必需的三个扩展是否加载 php -m | grep -E '^(openssl|curl|json)$' # 输出应为三行,缺哪个装哪个,以 Debian 系为例 # apt install php-openssl php-curl php-json这条命令的逻辑很简单:php -m列出所有已加载模块,再用 grep 精确匹配三个名字。用^(...)$锚定首尾是为了避免匹配到openssl_x之类的衍生名造成误判。缺扩展时页面通常报「Call to undefined function」,但错误可能被框架的异常处理器吞掉,看起来就是白屏,所以这一步提前做掉。
2.2 解压之前先核对文件结构
拿到包别急着 unzip,先列一遍清单,确认里面有配置模板、证书存放目录、依赖描述文件和启动入口。这一步的意义在于:如果包里带的是模板配置而不是示例配置,你直接解压覆盖就会把别人的占位参数带进生产。
# 只看清单不解压,确认结构再动手 unzip -l wechatpad.zip # 输出关注三类: # 1) 配置模板文件(通常带 example / sample / template 后缀) # 2) 证书目录(存放 apiclient_cert / apiclient_key 的地方) # 3) 依赖描述文件(package.json / composer.json / pom.xml)unzip -l只读中央目录,不改动磁盘,是安全的。如果清单里出现.env这类实际配置文件而不是.env.example,说明打包的人把自己的环境一起打进去了,这种包要格外小心,解压后第一件事是重命名而不是直接用。
2.3 支付前置条件:六样东西缺一不可
环境装好只是「能跑」,能不能「跑通支付」取决于下面六项是否到位。我把它们和缺失后的典型现象放在一起,方便你对照排查。
| 前置项 | 从哪里获取 | 缺失后的现象 |
|---|---|---|
| 公众号或小程序 AppID | 后台账号信息 | 拉起时报「appid 不合法」 |
| 商户号 mchid | 商户平台 | 下单报「商户号不存在」 |
| APIv3 密钥 | 商户平台自行设置 | 回调验签失败,密文解不开 |
| 商户 API 证书与私钥 | 商户平台下载 | 下单报证书读取失败 |
| 平台证书 | 通过接口下载或随包附带 | 回调验签拿不到公钥 |
| 支付授权目录 / JS 接口安全域名 | 商户平台与公众号后台分别配置 | 前端报「当前页面 URL 未注册」 |
常见做法是先把这六项在纸面上列全,再去改配置文件。我见过太多先把配置改完再回头找参数的,改到一半发现平台证书还没下,配置里那个路径一直是空的,启动日志里一条 warning 都没有,等到真下单才炸。
2.4 服务器时间与字符集,两个看不见的变量
签名和时间戳强相关,服务器时间偏超过五分钟,商户参数再对也过不了。装完系统第一件事应该是校时:
# 查看当前时间与时间同步状态 timedatectl status # 若 NTP 未启用,打开并立即同步 timedatectl set-ntp truetimedatectl status会输出System clock synchronized: yes才算合格。容器环境要特别注意,宿主机时间对了,容器里如果没挂载时区文件,跑出来仍是 UTC,日志时间对不上,排查时会误判成「回调延迟」。
字符集这条更隐蔽。配置文件必须是 UTF-8 且不带 BOM,带 BOM 的文件在多数运行时里读出来头部会多三个字节,拼进签名串就变成一个永远对不上的差异。用一条命令确认:
# 检查配置与证书相关文本文件是否带 BOM(输出非空即有 BOM) file config/*.json | grep -i bom正常应该没有任何输出。有输出就说明文件被某个编辑器改过,用sed -i '1s/^\xEF\xBB\xBF//'去掉再存。
3. 解压与初始化:wechatpad.zip 落地到项目目录的完整步骤
前面把版本和前置条件定下来了,这一章开始真正往磁盘上落。顺序我坚持三步走:先摆目录,再装依赖,最后生成配置。顺序颠倒的后果是依赖装进了错误路径,配置模板复制到了源码目录,回头清理比重装还费劲。
3.1 解压位置与目录规划
不要解压到项目根目录,也不要解压到 Web 根目录。我一般统一放/opt/app/下面,按应用名建一级目录,权限收给专用账号:
# 1) 建目标目录,属主交给运行账号(假设运行账号为 deploy) sudo mkdir -p /opt/app/wechatpad sudo chown -R deploy:deploy /opt/app/wechatpad # 2) 解压到目标目录,-q 静默、-o 覆盖不询问 sudo -u deploy unzip -q -o wechatpad.zip -d /opt/app/wechatpad # 3) 确认解压结果与顶层结构 ls -la /opt/app/wechatpad-d指定目标目录,-o表示覆盖已有文件不提示,适合自动化脚本;手工操作建议去掉-o,让它在冲突时停下来问一句,避免把已有配置覆盖掉。sudo -u deploy是为了让解压出来的文件属主直接就是运行账号,省掉后面一次 chown。
关键点在于:整个目录树里只有public或web这类子目录可以被 Web 服务器指向,配置文件和证书目录必须在 Web 根之外。目录摆错,证书就等于公开可下载,这是一类被反复提及但每年都有人犯的错误。
3.2 依赖安装:锁文件优先,别用 install
依赖安装这一步,Node 和 PHP 两条线的做法不一样,但原则一致——以锁文件为准,不要让它重新解析版本范围。
# Node 线:ci 严格按 lock 文件安装,装不上就失败,不会偷偷升级 cd /opt/app/wechatpad npm ci --omit=dev # 需要构建产物时再补一次完整安装 # npm ci && npm run buildnpm ci和npm install的差别值得说清楚。前者会先删掉node_modules再按 lock 文件精确安装,任何对不上就报错退出;后者会尝试解析 semver 范围,可能装出一个 lock 文件里没有的版本。支付这类对加密库版本敏感的场景,我坚持用ci,宁可装不上,也不要装出一个「看起来能跑」的组合。
PHP 线同理:
# 生产依赖,跳过开发包 composer install --no-dev --optimize-autoloader # 确认关键扩展在运行时可用 php -r "var_dump(extension_loaded('openssl'), extension_loaded('curl'));"--no-dev跳过测试与调试依赖,--optimize-autoloader生成类映射表,减少运行时的文件查找开销。第二行那个php -r是兜底检查,命令行环境有扩展不代表 FPM 环境也有,两者用的php.ini可能不是同一份,这一点在 Debian 系上尤其常见。
Java 线用 Maven 的话,把依赖下载与打包分开,避免每次改配置都重新拉一遍仓库:
# 先离线拉齐依赖 mvn -q dependency:go-offline # 再打包,跳过测试 mvn -q clean package -DskipTests3.3 配置生成与首次启动
配置永远从模板复制,不要手写空文件。模板里通常带齐了所有键,手写容易漏,漏掉的键在很多实现里不会报错,只会取默认值,然后行为诡异。
# 1) 从模板复制,保留模板不动 cd /opt/app/wechatpad cp config/config.example.json config/config.json # 2) 收紧权限:配置里有密钥,不能给别人读 chmod 600 config/config.json chmod 700 certs/ # 3) 首次启动,前台跑,方便看启动日志 node app.js第二步的权限设置是必做项。600表示只有属主可读写,700表示证书目录只有属主能进。如果运行账号和部署账号不是同一个,用chown一并调整,否则会出现「明明文件在,就是读不到」的经典现象。
启动后不要急着测支付,先访问健康检查端点,确认进程活着、配置加载成功:
# 健康检查,返回 200 且带状态字段说明启动正常 curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/health # 看启动日志里配置加载的那几行 tail -n 50 logs/app.log | grep -iE 'config|loaded|cert'-o /dev/null丢弃响应体只看状态码,-w输出格式化的结果。健康检查通过只代表进程活着,日志里那几行才是关键:证书路径有没有被解析到、APIv3 密钥长度对不对、回调地址是不是空的,这些在启动阶段就该暴露出来,不要留到下单时才发现。
4. 配置参数逐项拆解:JSAPI 支付的签名、回调与证书路径
配置文件里字段不多,但每一个都有明确的来源和明确的错法。这一章按「参数从哪来、写错了什么现象、怎么改对」逐项过一遍,重点是签名方式和证书路径这两块,它们占了实际故障的大头。
4.1 核心参数对照
| 配置键 | 含义 | 来源 | 典型错法 |
|---|---|---|---|
appid | 应用唯一标识 | 公众号或小程序后台 | 与 mchid 不匹配,跨主体绑定 |
mchid | 商户号 | 商户平台 | 抄成了子商户号 |
serial_no | 商户证书序列号 | 商户平台证书详情 | 与私钥不配套 |
private_key_path | 商户私钥路径 | 下载的证书包 | 指向了证书而非私钥 |
api_v3_key | APIv3 密钥 | 商户平台设置 | 长度不足 32 位 |
notify_url | 支付结果回调地址 | 自行部署 | 内网地址或带查询串 |
platform_cert_path | 平台证书路径 | 接口下载 | 过期未更新 |
serial_no和私钥必须配套,这一点最容易忽略。重新在商户平台申请过证书的人,如果只换了私钥没换序列号,签名时会报「证书序列号不存在」,报错信息指向证书,实际是配置里的号对不上。
4.2 签名方式的取舍:APIv2 与 APIv3 不要混用
APIv2 用 MD5 或 HMAC-SHA256 拼串签名,APIv3 用 SHA256-RSA 对请求报文签名。两套并存,但同一次调用只能走一套。混用的典型症状是下单成功了、回调验签失败了,因为下单走的 v3、回调按 v2 的逻辑验,密钥和算法都对不上。
// APIv3 请求签名的最小实现,注意签名串的构造顺序 const crypto = require('crypto'); function buildSignature(method, urlPath, timestamp, nonce, body, privateKey) { // 1) 签名串四行加一个换行结尾,顺序固定,不可调换 const message = `${method}\n${urlPath}\n${timestamp}\n${nonce}\n${body}\n`; // 2) SHA256-RSA,密钥来自 apiclient_key.pem return crypto.createSign('RSA-SHA256').update(message).sign(privateKey, 'base64'); }这段代码的逻辑:签名串由请求方法、URL 路径、时间戳、随机串、请求体五段拼成,每段之间用\n分隔,最后一段后面也要有一个\n。最后那个换行是最常被 trim 掉的地方,去掉就永远签不过。urlPath只取路径部分,不带域名和查询串,带了也会失败。
私钥读取要做一次格式校验,PEM 头尾必须在:
# 确认私钥文件格式正确 head -n 1 certs/apiclient_key.pem # 期望输出:-----BEGIN PRIVATE KEY----- # 用 openssl 验证私钥与证书是否配套(两者模数应一致) openssl x509 -noout -modulus -in certs/apiclient_cert.pem | openssl md5 openssl rsa -noout -modulus -in certs/apiclient_key.pem | openssl md5两条命令输出的 md5 相同,说明证书和私钥是配套的。不相同就说明下载时配错了对,这在批量申请证书的场景里很常见。
4.3 回调地址与授权目录的写法
回调地址必须是公网可达的 HTTPS 地址,不能带查询参数,不能是 IP 加端口这种非标组合——多数实现会直接拒绝。本地开发时回调打不进来,常见做法是准备一台有公网地址的测试机做落点,把回调日志打到那台机器上,不要试图在本地回环地址上收回调。
{ "notify_url": "https://pay-test.example.com/api/pay/notify", "auth_dir": "https://h5.example.com/pay/" }两个字段的写法要点:notify_url路径要精确到处理函数,不要指向首页再靠路由分发,多一层分发就多一次出错机会;auth_dir必须以斜杠结尾,表示这是一个目录而不是一个页面,少了斜杠,同目录下的子页面就匹配不上,前端报「当前页面 URL 未注册」,而后台配置看起来完全正常。
授权目录还要注意协议和端口。配置里写的是 HTTPS,前端页面用 HTTP 打开,一样不匹配;配置里没写端口,前端页面跑在 8080 上,也不匹配。这条规则严格到有点不近人情,但确实如此。
4.4 证书路径在 Linux 与 Windows 下的差异
Linux 用正斜杠,Windows 用反斜杠,写在 JSON 里反斜杠还要转义成双反斜杠,这是最琐碎的一处。我一般统一在配置里写正斜杠,Windows 下的运行时基本都能识别,省掉转义的心智负担。
{ "private_key_path": "certs/apiclient_key.pem", "platform_cert_path": "certs/platform_cert.pem" }路径统一用相对路径,相对于应用根目录解析,不要写绝对路径。绝对路径在容器里换个挂载点就失效,相对路径至少还能靠工作目录兜住。真正要写绝对路径时,用环境变量注进去,别硬编码在配置文件里。
5. 排查与避坑:JSAPI 支付装完之后最常见的五个翻车点
装完能启动,和装完能收款,中间隔着一段距离。这一章把这几年反复遇到的五个坑按「现象、原因、解决」写下来,顺序按出现频率排。
5.1 五条踩坑记录
第一条:签名错误,参数核对三遍全对
现象是拉起支付时报签名错误,把 appid、mchid、时间戳、随机串逐个比对,一个字符不差。原因是签名串末尾那个换行在序列化时被去掉了,或者配置文件的 BOM 混进了首字段。解决方式是打印出参与签名的原始串,用十六进制看一眼首尾字节:
# 把签名串写入文件后看首尾字节,确认有无 BOM 与多余空白 xxd -l 16 /tmp/sign_input.txt xxd -s -16 /tmp/sign_input.txt首部出现efbb bf就是 BOM,尾部不是0a就说明换行丢了。两者都清掉再签。
第二条:回调一直被重复推送,业务侧却显示已处理
现象是订单状态已更新,但平台侧持续重推同一条回调,隔几分钟来一次。原因是处理函数没有返回约定的成功应答,或者业务处理耗时超过了平台等待窗口。常见做法是把「验签 + 落库」做成快速路径,把「发货、通知」这类动作丢进队列异步做,先回成功应答再说。
// 回调处理的最小骨架:先验签,再落库,最后立即应答 app.post('/api/pay/notify', async (req, res) => { const ok = verifySignature(req); // 验签失败直接拒绝,不落库 if (!ok) return res.status(401).send('fail'); await saveOnce(req.body); // 幂等写入,重复推送不重复处理 res.status(200).send('success'); // 应答必须同步返回 });saveOnce要做幂等,用订单号做唯一键,重复推送时命中唯一约束直接跳过,不要抛异常。应答必须是同步返回的状态码加固定文本,异步回调和自定义文本都可能被判为失败。
第三条:授权目录配了,前端还是报 URL 未注册
现象是后台配置里明明有这条域名,前端仍然报错。原因集中在三处:目录末尾少了斜杠、配置的是 HTTP 而页面跑在 HTTPS、或者页面在带端口的地址上打开。解决方式是拿前端实际的location.href和配置值逐字符对齐,包括协议、域名、端口、路径四段,一段都不能差。
第四条:证书读取失败,权限看着没问题
现象是日志报 permission denied,ls -l看文件是 644,理论上可读。原因是上级目录缺执行权限,进程进不去那个目录,自然读不到文件。另一种可能是 SELinux 或 AppArmor 拦住了非标准路径。解决方式是逐级检查目录权限:
# 逐级检查证书所在路径每一层的权限 namei -l /opt/app/wechatpad/certs/apiclient_key.pemnamei -l会把路径上每一层目录的权限都列出来,哪一层缺x一眼就能看到。常见的修法是给目录补chmod 750,而不是把文件改成 777。
第五条:测试环境能付,生产环境不行
现象是同一份代码,测试跑得好好的,切到生产就报商户号不存在或者签名失败。原因是两个环境用了不同的 appid / mchid 组合,或者配置文件被测试环境的覆盖了。解决方式是在启动日志里把 appid 和 mchid 的后四位打出来做交叉确认,生产与测试的配置目录彻底分开,不要共用同一份config.json。
5.2 日志该看哪几行
排查支付问题,日志比调试器好使。启动阶段看三行:配置加载结果、证书路径解析结果、回调地址是否为空。运行阶段看两行:签名前的原始串摘要、回调验签的布尔结果。这两行不要打全量明文,打摘要和长度就够,避免密钥进日志。
# 按关键字过滤出与支付相关的日志行 grep -iE 'sign|notify|cert|mchid' logs/app.log | tail -n 100日志级别调到 debug 只在一段时间内有效,排查完立刻调回,否则日志量会把磁盘写满,这件事我吃过一次亏。
6. 装完怎么验证:一条从下单到回调落库的完整链路
验证不要只看「支付页面能弹出来」,弹出来只是第一步。完整的验证链路是:下单接口返回预支付参数、前端用参数拉起、支付完成后回调到达、回调验签通过、订单状态落库。五步里任何一步断了,都不能算装完。
先在下单接口上打一发,确认返回结构里带齐了appId、timeStamp、nonceStr、package、signType、paySign六个字段:
# 调本地下单接口,检查返回的预支付参数是否齐全 curl -s -X POST http://127.0.0.1:8080/api/pay/create \ -H 'Content-Type: application/json' \ -d '{"orderNo":"TEST20240101001","amount":1,"openid":"<测试用户openid>"}' \ | python3 -m json.toolpython3 -m json.tool只是把返回体格式化,方便肉眼核对字段。重点看两处:paySign不为空且长度合理,package是以prepay_id=开头的串。缺字段说明后端组装那步没走通,先回去看日志,不要在前端瞎调。
再模拟一次回调,绕过真实支付,直接验证验签与落库逻辑:
# 用测试工具向回调地址发一条构造报文,验证验签与幂等落库 curl -s -X POST https://pay-test.example.com/api/pay/notify \ -H 'Content-Type: application/json' \ -H 'Wechatpay-Signature: <构造的签名>' \ -d @notify_sample.json # 连续发两次,检查订单表是否只有一条记录(验证幂等)第二条的意义在于验证幂等。第一次应当返回成功并写入订单,第二次应当同样返回成功但不产生重复记录。如果第二次报唯一键冲突并返回 500,说明幂等逻辑写成了「靠异常兜底」,平台会一直重推,永远收不到成功应答。
把五步的期望结果和失败时该先看哪里列成一张表,验证时照着走:
| 验证项 | 期望结果 | 失败先看 |
|---|---|---|
| 健康检查 | 200,配置加载成功 | 启动日志的 config 行 |
| 下单接口 | 返回六个预支付字段 | 证书路径、mchid |
| 前端拉起 | 出现支付面板 | 授权目录、当前页面 URL |
| 回调到达 | 日志出现 notify 记录 | 回调地址是否公网可达 |
| 回调落库 | 订单表一条记录,重复推送不新增 | 幂等键与应答文本 |
这套流程我从那次 Node 版本升级导致的签名故障之后就没断过,每次重装环境,哪怕只是换台机器,也强制把五步走完再交付。跳过任何一步,后面都会以更难查的形式回来找你。希望帮到你。
本文还有配套的精品资源,点击获取