ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

JSAPI支付接入避坑:wechatpad.zip安装、APIv3签名与回调验签

JSAPI支付接入避坑:wechatpad.zip安装、APIv3签名与回调验签

简介:这份资源是一套可运行的 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.js18 LTSOpenSSL 3.x稳长期支持,生态兼容性最好
Node.js20 LTSOpenSSL 3.x稳新项目首选,注意旧算法默认禁用
Node.js16OpenSSL 1.1.1可用但不建议已停止维护,新机器别再用
PHP8.1 / 8.2OpenSSL 3.x稳扩展需开 openssl、curl、json
PHP7.4OpenSSL 1.1.1勉强可用部分新语法不兼容,谨慎
Java17自带稳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 true

timedatectl 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 build

npm 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 -DskipTests

3.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_keyAPIv3 密钥商户平台设置长度不足 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.pem

namei -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.tool

python3 -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 版本升级导致的签名故障之后就没断过,每次重装环境,哪怕只是换台机器,也强制把五步走完再交付。跳过任何一步,后面都会以更难查的形式回来找你。希望帮到你。

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

RELATED READING

延伸阅读

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