简介面向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 17mvn package一切正常java -jar起来三秒后抛InaccessibleObjectException退出或者 Redis 装了最新版但没设密码服务端连着连着就被人扫到。这一章先把四个组件的版本边界划出来再逐个给安装命令。2.1 一张版本对照表先把不该混的混掉下表的「建议版本」是我在几台机器上跑通过组合「最低要求」来自常见依赖的下限不是官方承诺。工程里pom.xml的maven.compiler.source决定了它到底能吃哪个 JDK动手前先grep一下再决定装什么。组件建议版本最低要求关键说明JDK8u202 及以上 / 11817 需补--add-opens反射模块会拦Maven3.6.3 及以上3.3低于 3.3 解析不了部分插件元数据Redis5.0 及以上4.0存登录态与二维码 ticket必须带密码MySQL8.0 或 5.75.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.langALL-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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 支付订单映射表内部单号与微信侧单号一一对应用于回调幂等 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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.skiptrue # 打包成功后确认产物一般在 target 下 ls -lh target/*.jar-DskipTests只跳过执行、仍然编译测试代码-Dmaven.test.skiptrue连编译都跳过后者更快但会漏掉测试代码里的编译错误。首次部署用后者省时间等链路跑通后再用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] Descriptionwechatpad service Afternetwork.target redis.service [Service] Typesimple WorkingDirectory/opt/app/wechatpad # 时区与编码必须显式指定否则日志时间会差 8 小时 ExecStart/usr/bin/java -Duser.timezoneGMT8 -Dfile.encodingUTF-8 \ -Xms512m -Xmx1024m -jar /opt/app/wechatpad/target/wechatpad.jar \ --spring.config.location/opt/app/wechatpad/application.yml Restarton-failure RestartSec10 StandardOutputappend:/opt/app/wechatpad/logs/stdout.log StandardErrorappend:/opt/app/wechatpad/logs/stderr.log [Install] WantedBymulti-user.target几个参数值得单独说-Duser.timezoneGMT8不加的话容器里默认 UTC日志时间和你对不上排查支付超时类问题时会怀疑人生-Dfile.encodingUTF-8防止中文消息落库变问号--spring.config.location指向 jar 外部的配置文件这样改配置只需systemctl restart不用重新打包。Restarton-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?useUnicodetruecharacterEncodingutf8mb4serverTimezoneGMT%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 里的serverTimezoneGMT%2B8必须带MySQL 8 的驱动对时区敏感不带会抛The server time zone value is unrecognized。characterEncodingutf8mb4也要显式写明虽然新版驱动多数情况能自动识别但显式写出来省得以后换环境再踩一次。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 用 RSAv2 是 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.langALL-UNNAMED --add-opens java.base/java.utilALL-UNNAMED并做好后面还会遇到第二个、第三个的准备。现象二启动卡在Started Application之后没有任何输出端口能连上但接口全 404。原因context-path配了非根路径而调用方还在按根路径请求或者server.port被环境变量覆盖。解决先curl -i http://127.0.0.1:8080/actuator/health确认实际路径再核对application.yml与启动参数里的server.portsystemd 的Environment优先级高于配置文件别忘了查。现象三扫码登录成功几分钟后掉线日志里反复出现重连。原因device-key与另一台实例重复登录态被互相覆盖或者heartbeat-interval设得过小触发服务端限制。解决redis-cli -a 密码 keys wp:session:*看键的数量和内容确认是否有多实例写入同一键把心跳间隔调到 30 秒以上再观察。5.2 支付链路的两个致命配置现象四下单接口返回 prepay_id前端调起时报「签名错误」。原因JSAPI 支付的paySign是用商户私钥对appId\ntimeStamp\nnonceStr\nprepay_idxxx\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 行2Redis 连通状态键随登录动作出现密码、database 编号3扫码登录日志出现登录成功心跳持续 5 分钟不掉device-key 是否重复4消息收发发一条消息能收到回执Redis 无重复键消息去重键的过期时间5JSAPI 支付下单拿到 prepay_id支付后回调返回 200notify-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是否为当前有效的平台证书序列号平台证书会轮换写死在配置里的旧序列号迟早失效。从那以后我每次部署这类带支付的服务都强制走一遍「下单 → 手动构造回调 → 重复推送一次」这三步不管业务方多急都不跳。跳过这一步省下的半小时通常会在对账那天以十倍的时间还回来。希望帮到你。本文还有配套的精品资源点击获取