简介:面向Java开发者及需要接入微信JSAPI支付的团队,提供一套可配置后使用的支付实现参考,覆盖公众号内支付场景中签名、下单、回调与配置等关键环节,适合已有一定Java Web基础、希望快速搭建联调环境或排查接入问题的人员。压缩包共111个文件,约18.29MB,以jar依赖、class与java源码、xml和properties配置、jsp页面及js脚本为主,兼有少量说明与工程元数据,便于按支付核心类、请求工具、控制器示例和配置项分层查阅。已有4243人学习下载,说明其在支付接入参考方面具备一定实用价值。读者可从中获得JSAPI支付核心类与工具类的组织方式、支付请求与回调处理思路、配置类与常量定义参考,以及结合页面和配置文件进行联调排错的线索,适合用作项目接入前的对照样例和问题定位辅助。
1. 上线前一晚卡在回调:wechatpad 装完不等于跑通
拿到 wechatpad.zip 的人通常都很急——业务侧已经把 iPad 协议登录、消息收发这条链路讲完了,剩下就等着环境跑起来。真正让人熬夜的往往不是解压和mvn package,而是 wechatpad 安装完之后接 JSAPI 支付那一段:下单接口能返回 prepay_id,前端调起却报签名错误;回调地址填了台内网机器,日志里一条通知都收不到。这套环境本身不复杂,JDK、Maven、Redis,再加一个能收 HTTPS 回调的域名,四样凑齐就能跑;装不好则表现为登录秒掉线、消息重复推送、支付回调验签永远失败。这篇笔记写给第一次部署 wechatpad 的开发者,也写给已经跑起来但支付链路没打通的人,从版本选型一路写到装坏了怎么救。
2. 环境清单与版本对齐:JDK、Maven、Redis 三件套怎么选
wechatpad 这类基于 Java 的协议侧服务,装不起来的原因十有八九不是代码问题,而是版本对不上。我见过最多的组合是:机器上默认是 JDK 17,mvn package一切正常,java -jar起来三秒后抛InaccessibleObjectException退出;或者 Redis 装了最新版但没设密码,服务端连着连着就被人扫到。这一章先把四个组件的版本边界划出来,再逐个给安装命令。
2.1 一张版本对照表,先把不该混的混掉
下表的「建议版本」是我在几台机器上跑通过组合,「最低要求」来自常见依赖的下限,不是官方承诺。工程里pom.xml的maven.compiler.source决定了它到底能吃哪个 JDK,动手前先grep一下再决定装什么。
| 组件 | 建议版本 | 最低要求 | 关键说明 |
|---|---|---|---|
| JDK | 8u202 及以上 / 11 | 8 | 17 需补--add-opens,反射模块会拦 |
| Maven | 3.6.3 及以上 | 3.3 | 低于 3.3 解析不了部分插件元数据 |
| Redis | 5.0 及以上 | 4.0 | 存登录态与二维码 ticket,必须带密码 |
| MySQL | 8.0 或 5.7 | 5.6 | 非必须,只在要持久化会话和订单时启用 |
| 操作系统 | Linux x86_64 | — | 生产别用 Windows 跑长连接服务 |
Redis 在这里不是可选项。iPad 协议登录成功后拿到的凭证、扫码过程中的临时 ticket、消息去重用的消息 ID,一般都会落在 Redis 里,进程重启后还要能续上,所以它既是缓存也是状态存储,挂了等于全体掉线。MySQL 则视业务而定,只做消息转发可以不上,一旦要落订单和支付流水就必须有。
2.2 装 JDK 8 并确认编译目标对得上
先看机器上现有的是什么,别急着装新的:
# 看当前默认 JDK 与 Maven 用的 JDK 是否是同一个 java -version mvn -v # 如果装了多个版本,显式切换(以常见的 alternatives 管理为例) sudo update-alternatives --config java sudo update-alternatives --config javacmvn -v输出里那行Java version才是关键,很多人java -version是 8,但JAVA_HOME指向 17,编译出来的 class 版本是 61,运行时就报UnsupportedClassVersionError。统一做法是在/etc/profile.d/jdk.sh里写死JAVA_HOME并export PATH=$JAVA_HOME/bin:$PATH,重新登录后两个命令输出必须一致。
至于为什么不建议直接上 JDK 17:这类工程大多基于 Spring Boot 2.x,启动时会反射访问java.lang内部成员,17 的模块系统默认不放行,需要额外加一串--add-opens java.base/java.lang=ALL-UNNAMED之类的参数。能跑,但每加一个依赖就可能多一个坑,装环境阶段没必要给自己找事,先用 8 或 11 把链路跑通,再谈升级。
2.3 Redis 必须设密码,顺手关掉高危命令
Redis 的安装本身没什么技术含量,包管理器一行就够,真正决定后面稳不稳的是redis.conf里这几行:
# /etc/redis/redis.conf 关键项 bind 127.0.0.1 -::1 protected-mode yes requirepass 一串足够长的随机串 port 6379 # 生产环境建议禁用或重命名的高危命令 rename-command FLUSHALL "" rename-command FLUSHDB "" rename-command CONFIG ""bind只留回环地址,意味着 Redis 只允许本机访问;如果 wechatpad 和 Redis 不在同一台机器,就把bind换成内网网卡 IP,同时用安全组或防火墙限制来源,千万别写成0.0.0.0加空密码——这是最常见的翻车现场,暴露在公网上的空密码 Redis 几小时内就会被写进定时任务。rename-command把清库和改配置的命令置空,是为了防止误操作和后期被人利用,改完记得重启服务并用redis-cli -a 密码 ping确认返回 PONG。
连接串的写法也要注意,密码里带@、#这类字符时必须 URL 编码,否则 Spring 解析spring.redis.password时会把后面半截当成 host,报错信息还特别含糊,只告诉你连接超时。
2.4 启用持久化时,MySQL 建库和字符集一次到位
如果确定要把会话和支付订单落库,建库语句建议一次性把字符集定死。utf8mb4 是必须的,微信侧的昵称、消息内容里 emoji 不算少见,用 utf8 会在插入时直接报Incorrect string value。
-- 建库,字符集与排序规则显式指定,避免继承服务器默认值 CREATE DATABASE wechatpad DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 会话表:一台设备一条记录,device_key 做唯一约束 CREATE TABLE wp_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_key VARCHAR(64) NOT NULL COMMENT '设备标识,与配置文件一致', wxid VARCHAR(64) DEFAULT NULL COMMENT '登录后返回的内部账号标识', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未登录 1已登录 2已失效', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_device (device_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 支付订单映射表:内部单号与微信侧单号一一对应,用于回调幂等 CREATE TABLE wp_pay_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, out_trade_no VARCHAR(64) NOT NULL COMMENT '商户侧单号', transaction_id VARCHAR(64) DEFAULT NULL COMMENT '支付侧流水号', total_fee INT NOT NULL COMMENT '单位:分', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已关闭', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_out_trade_no (out_trade_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;wp_pay_order上的uk_out_trade_no唯一索引不是可选项。支付回调可能重复推送,也可能因为网络抖动被重试多次,靠这张表的唯一约束加上状态机判断,才能在数据库层面挡住重复入账,比在代码里if (status == 1) return可靠得多。建完表用SHOW CREATE TABLE wp_pay_order;复核一遍字符集,有些托管数据库会忽略建表语句里的 charset 而套用实例默认值。
3. 解压与编译:wechatpad.zip 落到本地工程的四步
环境齐了就可以动压缩包。这一步看着简单,但目录里哪几个文件该改、哪几个千万别动,决定了后面排查时能不能快速定位。我的习惯是先不急着mvn package,花五分钟把结构看清楚,比编译失败后翻半天日志划算。
3.1 解压后先看目录:哪些该改,哪些别碰
# 解压到工作目录,注意用 unzip -O 指定编码,避免中文注释变乱码 mkdir -p /opt/app && cd /opt/app unzip -O UTF-8 wechatpad.zip -d wechatpad cd wechatpad && ls -la解压后一般能看到下面这几类目录,职责要分清:
| 路径 | 作用 | 是否要改 |
|---|---|---|
src/main/resources/application.yml | 全部运行参数入口 | 必须改 |
src/main/resources/下证书类文件 | 支付证书、密钥 | 按需替换 |
src/main/java/ | 业务与协议实现 | 一般不改 |
pom.xml | 依赖与编译级别 | 视 JDK 调整 |
logs/或配置里的日志目录 | 运行日志 | 需确认可写 |
application.yml是唯一必须动的地方,其余源码目录在没有明确需求前不要改,改了之后下次拿新包又得重新对比,血泪经验。证书文件放在resources下会被打进 jar,方便但不利于轮换;我一般把它挪到 jar 外部,用绝对路径引用,换证书时只替换文件不重新打包。
3.2 Maven 首次打包:本地仓库与镜像先配好
第一次mvn大概率会卡在下载依赖上。先把~/.m2/settings.xml配好,指向一个可用的公共仓库镜像,再执行打包:
# 清理并打包,跳过测试(首次部署先跳过,跑通再补) mvn clean package -DskipTests -Dmaven.test.skip=true # 打包成功后确认产物,一般在 target 下 ls -lh target/*.jar-DskipTests只跳过执行、仍然编译测试代码,-Dmaven.test.skip=true连编译都跳过,后者更快但会漏掉测试代码里的编译错误。首次部署用后者省时间,等链路跑通后再用mvn clean package完整跑一遍,确认没有隐藏问题。
如果卡在依赖下载,多半是镜像地址不通或者settings.xml里mirrorOf写成了*导致私服依赖也被劫持。判断方法很简单:看报错里的 URL,如果是公网地址超时,换镜像;如果是私服地址被改写,把mirrorOf改成*,!internal这种排除写法。
编译报invalid target release: 11之类,说明JAVA_HOME和pom.xml里声明的版本对不上,回到 2.2 节重查一遍,别在 pom 里硬改版本号凑合。
3.3 产物与启动脚本:别用 nohup 裸跑
打包出来的 jar 直接java -jar能跑,但生产环境不建议这么干,进程挂了没人拉起来。用 systemd 托管是最省心的做法:
# /etc/systemd/system/wechatpad.service [Unit] Description=wechatpad service After=network.target redis.service [Service] Type=simple WorkingDirectory=/opt/app/wechatpad # 时区与编码必须显式指定,否则日志时间会差 8 小时 ExecStart=/usr/bin/java -Duser.timezone=GMT+8 -Dfile.encoding=UTF-8 \ -Xms512m -Xmx1024m -jar /opt/app/wechatpad/target/wechatpad.jar \ --spring.config.location=/opt/app/wechatpad/application.yml Restart=on-failure RestartSec=10 StandardOutput=append:/opt/app/wechatpad/logs/stdout.log StandardError=append:/opt/app/wechatpad/logs/stderr.log [Install] WantedBy=multi-user.target几个参数值得单独说:-Duser.timezone=GMT+8不加的话,容器里默认 UTC,日志时间和你对不上,排查支付超时类问题时会怀疑人生;-Dfile.encoding=UTF-8防止中文消息落库变问号;--spring.config.location指向 jar 外部的配置文件,这样改配置只需systemctl restart,不用重新打包。Restart=on-failure让进程异常退出后 10 秒自动拉起,比nohup挂后台强太多。
改完执行systemctl daemon-reload && systemctl enable --now wechatpad,然后systemctl status wechatpad看状态,接着journalctl -u wechatpad -f盯日志。
4. 配置逐项拆解:application.yml 里的账号、存储与支付参数
配置文件是整个部署里唯一需要动脑的地方,写错一个字段的表现往往不是启动失败,而是运行到某个环节才暴露。这一章按「账号 → 存储 → 支付 → 运行」四段拆,每段给一份可直接抄的片段,再解释哪些值能改、哪些必须和外部平台一致。
4.1 账号段:appId、appSecret 与设备标识
wechat: app-id: wx0000000000000000 # 应用唯一标识,与支付侧配置必须一致 app-secret: 替换为实际密钥 # 只用于换取凭证,切勿提交到代码仓库 device-key: pad-01 # 本机设备标识,多实例部署时不能重复 auto-restart: true # 掉线后自动重连 heartbeat-interval: 30 # 心跳间隔,秒;过小会触发风控device-key是个容易忽略但很关键的字段。它决定 Redis 里状态键的前缀和数据库里的唯一约束,两台机器如果用了同一个值,会互相覆盖登录态,表现为「刚扫码成功,另一台一重启就掉线」。多实例部署时按机器名或 IP 后缀区分,比如pad-01、pad-02。
heartbeat-interval不要设得太小。设成 5 秒看起来更「实时」,实际上是给自己找麻烦,服务端会判定为异常频率,表现为频繁重连、消息乱序。30 秒是常见做法,网络差的链路可以放宽到 60 秒。
app-secret这类敏感值,我一般不从 yml 里明文读,而是用环境变量占位:app-secret: ${WP_APP_SECRET},在 systemd 的Environment=或EnvironmentFile=里注入。这样配置文件可以进版本库,密钥不进。
4.2 存储段:Redis 前缀与数据源
spring: redis: host: 127.0.0.1 port: 6379 password: ${WP_REDIS_PASSWORD} database: 0 timeout: 3000ms lettuce: pool: max-active: 16 # 连接池上限,按并发消息量调整 max-idle: 8 min-idle: 2 datasource: url: jdbc:mysql://127.0.0.1:3306/wechatpad?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=GMT%2B8 username: wp_user password: ${WP_DB_PASSWORD} hikari: maximum-pool-size: 10 connection-timeout: 3000Redis 的database建议单独开一个库给这个服务用,不要和别的业务混在0号库,否则排查时keys *一屏全是别人的键。连接池max-active不要一上来就设 128,先按 16 跑,观察lettuce的等待指标再调;设太大只会在 Redis 侧堆连接,服务端maxclients一到就集体报错。
数据源 URL 里的serverTimezone=GMT%2B8必须带,MySQL 8 的驱动对时区敏感,不带会抛The server time zone value is unrecognized。characterEncoding=utf8mb4也要显式写明,虽然新版驱动多数情况能自动识别,但显式写出来省得以后换环境再踩一次。
4.3 支付段:mchId、证书路径、回调地址
这一段是 JSAPI 支付能不能通的关键。参数名各家封装略有差异,但核心就那几个:
pay: enabled: true mch-id: 1600000000 # 商户号,纯数字 app-id: wx0000000000000000 # 必须与发起支付的 appId 完全一致 serial-no: 4A5B6C7D8E9F... # 商户 API 证书序列号,40 位十六进制 private-key-path: /opt/app/wechatpad/cert/apiclient_key.pem cert-path: /opt/app/wechatpad/cert/apiclient_cert.pem notify-url: https://your-domain.com/pay/notify # 必须公网 HTTPS,不带参数 sign-type: RSA # v3 用 RSA,v2 是 MD5/HMAC-SHA256证书的准备步骤不能省。拿到的通常是.p12格式,Java 侧用起来别扭,先转成 PEM:
# 从 p12 中导出证书(公钥部分) openssl pkcs12 -in apiclient_cert.p12 -clcerts -nokeys -out apiclient_cert.pem # 导出私钥,-nodes 表示不加密私钥,服务端读取时不用输密码 openssl pkcs12 -in apiclient_cert.p12 -nocerts -nodes -out apiclient_key.pem # 查看证书序列号,与配置里的 serial-no 核对 openssl x509 -in apiclient_cert.pem -noout -serialserial-no必须和证书实际序列号一致,不一致时的报错非常不直观,往往只告诉你「签名验证失败」,不会指出是序列号问题。转完用上面第三条命令打印出来,大写不带冒号,逐位对一遍。notify-url有三个硬性要求:必须是 HTTPS、必须是公网可达、不能带查询参数。填了内网地址的话,下单接口照样返回 prepay_id,但支付完成后永远收不到回调,订单状态一直挂在待支付,这种问题只靠看日志是发现不了的,得主动去核对。
4.4 端口、日志与编码:三个启动参数的取舍
server: port: 8080 servlet: context-path: / tomcat: max-threads: 200 # 消息回调并发高时再调 connection-timeout: 20000 logging: level: root: info com.example.wechatpad: debug # 首次部署开 debug,稳定后调回 info file: name: /opt/app/wechatpad/logs/app.logcontext-path如果改成非根路径,notify-url也要跟着加前缀,这两处不一致是回调 404 的常见原因。日志级别首次部署建议开debug,能看到签名串、请求体这些关键信息;稳定运行一周后调回info,否则日志一天几个 G,磁盘满了服务直接挂。logging.file.name指向的目录必须提前mkdir -p并给运行用户写权限,否则启动时不报错,直到第一条日志写不进去才抛异常。
5. 部署排查:五个把人卡到半夜的坑
下面五条都是我实际遇到过的,按「现象 → 原因 → 解决」记录,遇到类似表现可以先对号入座,能省掉不少翻源码的时间。
5.1 启动阶段的三类现象
现象一:java -jar起来几秒后自动退出,日志只有一行InaccessibleObjectException。
原因:JDK 版本高于工程编译目标,Spring Boot 2.x 反射访问 JDK 内部模块被模块系统拦截。解决:换回 JDK 8 或 11;实在要用 17,在启动参数里补--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED,并做好后面还会遇到第二个、第三个的准备。
现象二:启动卡在Started Application之后没有任何输出,端口能连上但接口全 404。
原因:context-path配了非根路径,而调用方还在按根路径请求;或者server.port被环境变量覆盖。解决:先curl -i http://127.0.0.1:8080/actuator/health确认实际路径,再核对application.yml与启动参数里的server.port,systemd 的Environment=优先级高于配置文件,别忘了查。
现象三:扫码登录成功,几分钟后掉线,日志里反复出现重连。
原因:device-key与另一台实例重复,登录态被互相覆盖;或者heartbeat-interval设得过小触发服务端限制。解决:redis-cli -a 密码 keys "wp:session:*"看键的数量和内容,确认是否有多实例写入同一键;把心跳间隔调到 30 秒以上再观察。
5.2 支付链路的两个致命配置
现象四:下单接口返回 prepay_id,前端调起时报「签名错误」。
原因:JSAPI 支付的paySign是用商户私钥对appId\ntimeStamp\nnonceStr\nprepay_id=xxx\n这串内容做 SHA256withRSA 签名,任何一处不一致都会失败——最常见的是appId用了公众号的而证书属于另一个应用,或者timeStamp用了毫秒。解决:把参与签名的原始串打到 debug 日志里逐字段比对,timeStamp必须是 10 位秒级字符串,package必须是prepay_id=前缀的完整值,signType固定RSA。
现象五:支付完成,但订单状态一直是待支付,回调接口没有任何访问记录。
原因:notify-url填了内网地址或带参数的地址;或者域名解析正常但 HTTPS 证书链不完整,支付侧校验失败后直接放弃重试。解决:先用curl -X POST -i https://your-domain.com/pay/notify从公网机器验证可达性和证书链完整性,再检查商户侧配置的回调地址与 yml 里是否逐字符一致。回调还有个隐蔽点:必须返回 HTTP 200 且响应体符合约定格式,返回 200 但 body 是{"code":"FAIL"}同样会被判定为失败并触发重试。
提示:这五条里,四条都能通过「把 debug 日志打开、把原始请求串打出来」快速定位。部署阶段别吝啬日志。
6. 装完怎么验证:从扫码登录到 JSAPI 支付回调跑通
装完不等于跑通,我一般按下面这张表逐项打勾,五项全过才算交付。任何一项跳过,后面出问题时都会怀疑是环境没装好,反而浪费更多时间。
| 序号 | 验证项 | 通过标准 | 失败先看哪 |
|---|---|---|---|
| 1 | 进程与端口 | systemctl status为 active,端口监听正常 | stderr 日志前 50 行 |
| 2 | Redis 连通 | 状态键随登录动作出现 | 密码、database 编号 |
| 3 | 扫码登录 | 日志出现登录成功,心跳持续 5 分钟不掉 | device-key 是否重复 |
| 4 | 消息收发 | 发一条消息能收到回执,Redis 无重复键 | 消息去重键的过期时间 |
| 5 | JSAPI 支付 | 下单拿到 prepay_id,支付后回调返回 200 | notify-url 与签名串 |
第三项建议真的等满五分钟再下结论。有些掉线是登录后 60 到 90 秒才发生的,扫完码看到「登录成功」就收工,第二天准出问题。
支付这一项,用一条 curl 就能把下单环节单独拎出来验证,不用等前端联调:
# 调用本服务的下单接口,注意金额单位是分 curl -i -X POST 'http://127.0.0.1:8080/pay/jsapi' \ -H 'Content-Type: application/json' \ -d '{ "openId": "用户在该 appId 下的 openid", "outTradeNo": "TEST20240101000001", "total": 1, "description": "联调测试一分钱" }'这里的openId是最容易卡住的地方。iPad 协议登录后拿到的是内部账号标识,和公众号体系下的 openid 不是一回事,两者不能混用。常见做法是维护一张映射表:用户通过网页授权拿到 openid 后,与内部标识绑定存库,下单时按内部标识反查 openid。没有这层映射,下单接口会直接返回「openid 与 appid 不匹配」,而且报错文案不会提示你去建映射。outTradeNo每次测试换一个,碰撞了会返回「订单号重复」,别以为是接口坏了。
回调环节的验证要主动构造。支付侧重试策略是间隔递增的,等它推过来可能要十几分钟,不如自己模拟一次:
# 模拟回调,验证签名校验与幂等逻辑;签名需按实际规则生成 curl -i -X POST 'https://your-domain.com/pay/notify' \ -H 'Content-Type: application/json' \ -H 'Wechatpay-Signature: 按规则生成的签名' \ -H 'Wechatpay-Timestamp: 1700000000' \ -H 'Wechatpay-Nonce: randomstring' \ -H 'Wechatpay-Serial: 平台证书序列号' \ -d @notify_body.json重点看两件事:一是返回体是否是约定的成功格式,二是重复推送同一笔订单时,wp_pay_order里的status会不会被改成别的值。幂等没做对,重复回调会把已支付的订单再处理一遍,对账时数字对不上,查起来极其痛苦。验签失败时先核对Wechatpay-Serial是否为当前有效的平台证书序列号,平台证书会轮换,写死在配置里的旧序列号迟早失效。
从那以后我每次部署这类带支付的服务,都强制走一遍「下单 → 手动构造回调 → 重复推送一次」这三步,不管业务方多急都不跳。跳过这一步省下的半小时,通常会在对账那天以十倍的时间还回来。希望帮到你。
本文还有配套的精品资源,点击获取