
简介wechatpad.zip 是一份面向 Java 后端开发者的微信公众号 JSAPI 支付集成工程聚焦公众号网页内唤起微信支付这一典型场景提供配置后即可运行的实现代码适合正在对接微信支付、需要一份可参照骨架的初中级开发者。压缩包共 111 个文件、约 18.29MB以 44 个 jar 依赖、19 个 class 字节码、11 个 java 源码、16 个 xml 配置和 3 个 jsp 页面为主另有 properties、prefs 等配置项源码与编译产物并存便于直接部署或二次改造。代码整体围绕统一下单、支付签名、回调校验、配置加载与异常上报等环节组织模块边界清晰读者可据此理清公众号支付从下单到通知的完整链路。替换商户号、密钥与证书后即可跑通流程遇到签名错误、回调失效等问题时也能对照现有实现逐层排查。已有 4243 人学习下载。1. 付款按钮点下去却弹「支付失败」问题多半不在网络付款按钮按下去转圈两秒弹出一句「支付失败请稍后再试」——十次里有八次不是网络抖动而是 JSAPI 支付那几个签名参数字段顺序、大小写或者prepay_id前缀出了岔子。wechatpad 这份工程包拆的就是这条链路用户在公众号内嵌 H5 页面点购买前端拿服务端签好的六七个字段直接唤起收银台支付完成后异步通知回到自己的后端改单。它适合正在做 H5 商城、会员充值、课程付费这类内嵌页付费场景的开发者尤其是那种「支付接上了但偶发失败、对账老对不上」的项目。下面按目录结构、服务端下单签名、前端调起、回调幂等一路拆最后给出二次开发改多商户配置的做法。2. 工程骨架与目录结构wechatpad 里每个目录负责哪一段链路拿到一个支付工程包我一般不急着看业务代码先看它把「下单」「签名」「通知」这三件事分别放在哪。放得清楚后面改需求就省劲全糊在一个 Service 里那基本等于给自己埋雷。wechatpad 的骨架是典型的单体分层结构支付链路短、事务边界清晰用微服务反而要多维护一层 RPC 和分布式事务不划算。2.1 技术栈盘点与选型理由后端是 Spring Boot 打底配 MyBatis 做持久层MySQL 存订单Redis 放 openid 缓存和下单防重锁HTTP 客户端用 OkHttp 封装一层网关。前端没有上重型框架静态 H5 页面加原生 JS因为支付页最忌讳首屏慢——用户在收银台前多等一秒弃单率就往上走。页面里只引一个轻量的 JSSDK 封装文件负责参数注入和桥对象判断。选 MyBatis 而不是 JPA主要是订单表字段经常随业务加优惠券、渠道来源、分账标记手写 SQL 改起来直观也方便直接在 SQL 里做「状态 UNPAID 才更新」这种原子条件这是后面幂等的关键。选 Redis 做 openid 缓存是因为静默授权换 openid 那一步有频率限制同一个用户短时间内反复进页面每次都走一遍授权链路既慢又容易被限流缓存 30 分钟足够。依赖清单大致这几块Web 容器、MyBatis Starter、MySQL 驱动、Redis Starter、OkHttp、Lombok、以及做 XML 报文读写的工具库JDK 自带的 DOM 也行但支付报文结构固定用轻量工具更省事。版本号这里不铺开工程包里pom.xml已经锁好改动前先看有没有强制升级的传递依赖尤其是 HTTP 客户端和 XML 解析这两块升级后行为差异最容易翻车。2.2 目录结构与模块职责工程目录大致长这样我按职责标了注释wechatpad/ ├── pom.xml ├── src/main/java/com/example/pad │ ├── WechatPadApplication.java # 启动类开启配置属性绑定 │ ├── config/ │ │ ├── PayProperties.java # 支付参数绑定appId/mchId/apiKey 等 │ │ ├── HttpClientConfig.java # OkHttp 超时、连接池 │ │ └── RedisConfig.java # 序列化方式避免乱码 │ ├── controller/ │ │ ├── PayController.java # 下单、查单、结果页跳转 │ │ └── PayNotifyController.java # 异步通知入口独立出来便于加白名单 │ ├── service/ │ │ ├── JsapiPayService.java # 下单 组装前端参数 │ │ ├── PayOrderService.java # 订单状态机与落库 │ │ └── OrderQueryService.java # 主动查单补偿 │ ├── gateway/ │ │ ├── PayGateway.java # 支付平台调用抽象 │ │ └── impl/PayGatewayImpl.java # 统一下单、查单、关单 │ ├── domain/ │ │ ├── PayOrder.java # 订单实体 │ │ ├── OrderStatus.java # 状态枚举 │ │ └── dto/ # 请求/响应对象 │ ├── mapper/PayOrderMapper.java │ └── util/ │ ├── SignUtils.java # 签名与验签 │ ├── NonceUtils.java # 随机串 │ └── XmlUtils.java # 报文互转 ├── src/main/resources/ │ ├── application.yml # 主配置敏感项走环境变量 │ ├── mapper/PayOrderMapper.xml │ ├── cert/apiclient_cert.p12 # 退款/部分查单用的双向证书 │ └── db/schema.sql └── web/ # H5 静态页可单独部署到 CDN ├── index.html ├── js/pay.js └── css/pay.css这个结构里有两个地方值得说。一是PayNotifyController单独拆出来不跟普通业务接口混在一起因为通知接口要单独加日志、单独放行、单独限流混在PayController里以后加鉴权拦截器会误伤。二是gateway包把「调支付平台」这件事抽成接口业务层只依赖PayGateway将来换通道或者加沙箱环境改实现类就行不用动业务。2.3 配置与三套密钥别搞混支付相关的配置项最容易出事因为密钥不止一把。常见的有四类应用标识appId、商户号mchId、V2 签名用的apiKey32 位字符串、换取用户标识用的应用密钥appSecret再加一个退款和部分查询接口用的商户证书p12文件及其密码。这四类东西的作用域完全不同混用必翻车。pad: pay: app-id: ${PAY_APP_ID} # 应用唯一标识 mch-id: ${PAY_MCH_ID} # 商户号 api-key: ${PAY_API_KEY} # V2 签名密钥只用于签名不外传 app-secret: ${PAY_APP_SECRET} # 换 openid 用绝对不能下发到前端 notify-url: https://your-domain.com/pay/notify # 必须公网可达 gateway: https://api.example-pay.com # 网关地址沙箱环境换这里 cert-path: classpath:cert/apiclient_cert.p12 cert-password: ${PAY_CERT_PWD} expire-minutes: 110 # 预支付单有效期余量平台默认 2 小时配置里所有敏感值都走环境变量仓库里只留占位符这是血泪经验——把api-key提交到代码库然后被人扫到损失不是换个密钥能立刻兜住的。expire-minutes给 110 而不是 120是留十分钟余量避免前端拿着快过期的prepay_id去调起用户还在确认金额呢单子先过期了。3. 服务端下单与签名一次统一下单从建单到 prepay_id服务端这条链路是整条支付链路的地基先建本地订单拿到自己的单号再拿这个单号去换预支付标识最后把预支付标识加工成前端能直接用的参数。每一步失败报错信息都不一样所以要分清楚是哪一段出的问题。3.1 订单表设计与状态机订单表我一般这么建字段不多但每个都有用CREATE TABLE pay_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, out_trade_no VARCHAR(32) NOT NULL COMMENT 本地商户订单号全局唯一, open_id VARCHAR(64) NOT NULL COMMENT 付款用户标识, subject VARCHAR(128) NOT NULL COMMENT 商品描述, amount_fen INT NOT NULL COMMENT 金额单位分, status VARCHAR(16) NOT NULL DEFAULT UNPAID COMMENT UNPAID/PAID/CLOSED/REFUNDED, transaction_id VARCHAR(64) DEFAULT NULL COMMENT 平台侧交易号, client_ip VARCHAR(64) NOT NULL COMMENT 下单终端 IP, pay_time DATETIME DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_out_trade_no (out_trade_no), KEY idx_status_create (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;out_trade_no上加唯一索引这是第一道幂等防线哪怕前端因为连点发起了两次下单数据库直接拦掉。金额统一用「分」存整数不要用小数浮点数在金额上是绝对的灾难。idx_status_create这个联合索引是给补偿任务用的每分钟扫一遍「UNPAID 且创建超过两分钟」的单子去主动查单走这个索引不会全表扫。状态机只有四条边UNPAID → PAID支付成功、UNPAID → CLOSED超时关单、PAID → REFUNDED退款、其余一律不允许。所有状态变更都写成带条件的 UPDATE不用select查出来改完再update中间那段时间差就是并发问题的温床。3.2 统一下单请求的组装下单方法的核心就是把参数塞齐、签好名、发出去、解析回来。下面这段是简化的主干public String unifiedOrder(PayOrder order, String openId) { MapString, String p new LinkedHashMap(); p.put(appid, props.getAppId()); p.put(mch_id, props.getMchId()); p.put(nonce_str, NonceUtils.random32()); // 32 位随机串每次都要新生成 p.put(body, order.getSubject()); // 商品描述会展示给用户 p.put(out_trade_no, order.getOutTradeNo()); // 本地单号必须全局唯一 p.put(total_fee, String.valueOf(order.getAmountFen())); // 单位分整数 p.put(spbill_create_ip, order.getClientIp()); // 终端 IP取不到时用服务端出口 IP p.put(notify_url, props.getNotifyUrl()); // 回调地址必须公网可达 p.put(trade_type, JSAPI); // 内嵌页支付固定 JSAPI p.put(openid, openId); // 付款用户标识必传 p.put(sign_type, MD5); p.put(sign, SignUtils.sign(p, props.getApiKey(), MD5)); String xml XmlUtils.toXml(p); String resp httpClient.post(props.getGateway() /pay/unifiedorder, xml); MapString, String result XmlUtils.parse(resp); if (!SUCCESS.equals(result.get(return_code))) { throw new PayException(通信失败: result.get(return_msg)); } if (!SUCCESS.equals(result.get(result_code))) { // 业务失败err_code 才是真正要看的东西 throw new PayException(下单失败: result.get(err_code) - result.get(err_code_des)); } return result.get(prepay_id); // 预支付标识有效期两小时 }逻辑上有几个点必须拎出来。nonce_str每次请求都要重新生成不能复用复用会导致签名可被重放。notify_url必须是外网能访问到的地址带内网 IP 或者带非标准端口都可能收不到通知本地开发时得用端口映射工具临时暴露出去。返回值判断要分两层return_code是通信层result_code是业务层前者失败说明报文都没被正确接收后者失败才是参数或账户问题两者混在一起看会误判方向。HTTP 调用这块超时时间别设太长。支付平台的下单接口正常响应在几百毫秒量级连接超时三秒、读取超时五秒足够设成三十秒只会让你的线程池在下游抖动时被迅速占满。失败重试要谨慎——下单接口不是幂等的同一个out_trade_no重复提交会返回「订单已存在」所以重试前先查单别盲目重发。3.3 签名算法V2 那套字典序拼串签名这段代码不长但翻车率极高。规则说穿了很简单去掉空值和sign本身按参数名 ASCII 字典序排序拼成kvkv的形式末尾追加key密钥做摘要后转大写。public static String sign(MapString, String params, String apiKey, String signType) { // 1. TreeMap 天然按 key 字典序顺手过滤空值和 sign 自身 MapString, String sorted new TreeMap(); params.forEach((k, v) - { if (v ! null !v.isEmpty() !sign.equals(k)) { sorted.put(k, v); } }); // 2. 拼接待签串注意末尾追加的 key 是签名密钥不是参数 StringBuilder sb new StringBuilder(); sorted.forEach((k, v) - sb.append(k).append().append(v).append()); sb.append(key).append(apiKey); String raw sb.toString(); // 3. 按 signType 选摘要算法结果统一转大写十六进制 try { String algo MD5.equalsIgnoreCase(signType) ? MD5 : SHA-256; byte[] digest MessageDigest.getInstance(algo) .digest(raw.getBytes(StandardCharsets.UTF_8)); StringBuilder hex new StringBuilder(); for (byte b : digest) { hex.append(String.format(%02X, b)); } return hex.toString(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(摘要算法不可用, e); } }三个坑点。第一空值必须过滤某个字段传了空字符串却参与签名对方验签时按「不参与」处理两边算出来的结果必然对不上。第二字符集固定 UTF-8别让运行环境默认编码进来掺和尤其是 Windows 本地跑得好好的、上了 Linux 服务器就签名失败的八成是这个原因。第三转大写十六进制MD5 出来是小写字节流忘了转大写就是一堆「签名错误」的玄学报错。还有一点容易被忽略验签方向的参数是对方发过来的里面本身就带sign和可能带sign_type。验签时要把sign剔掉但sign_type要参与计算——这跟签名方向不完全对称很多人两边用同一个方法却没做参数区分结果回调永远验不过。3.4 组装给前端的支付参数拿到prepay_id之后不能直接扔给前端。前端需要的是另一组字段而且这组字段要再签一次名这是第二个高频翻车点。public MapString, String buildJsapiParams(String prepayId) { MapString, String m new TreeMap(); m.put(appId, props.getAppId()); // 注意大写 I m.put(timeStamp, String.valueOf(System.currentTimeMillis() / 1000)); // 秒级时间戳 m.put(nonceStr, NonceUtils.random32()); // 注意大写 S m.put(package, prepay_id prepayId); // 前缀 prepay_id 不能少 m.put(signType, MD5); m.put(paySign, SignUtils.sign(m, props.getApiKey(), MD5)); return m; }参数名的大小写是严格区分的appId、timeStamp、nonceStr、package、signType一个字母错了前端调起就直接失败而且报错信息极其含糊只说「参数错误」。package的值必须带prepay_id前缀只传裸的预支付标识是最常见的低级错误。timeStamp是秒级字符串不是毫秒。提示这组参数里有paySign但没有openidopenid是在服务端下单时用的不用下发到前端下发了反而多一层泄露面。4. 前端调起与回调落账从 JSBridge 到订单状态改成 PAID服务端参数备齐剩下就是让用户在页面上把收银台唤起来以及支付完成后怎么确认这笔钱真的到了。这两段经常被写得很随意尤其是「前端回调成功就当支付成功」这个写法在对账时能让人后悔药都吃不上。4.1 拿 openid 与页面侧的调起姿势内嵌页拿用户标识走的是静默授权拼一个授权链接让页面跳过去回跳时带上code服务端拿code换标识换完缓存起来。这一步不需要用户点确认但必须由服务端换因为要用到appSecret这个值一旦出现在前端 JS 里等于把商户密钥公开了。页面侧的调起逻辑要注意桥对象可能还没注入// 支付入口params 由服务端接口返回 function startPay(params) { if (typeof WeixinJSBridge undefined) { // 桥对象未注入挂一次性监听别用 setInterval 轮询 document.addEventListener(WeixinJSBridgeReady, function () { doInvoke(params); }, false); return; } doInvoke(params); } function doInvoke(params) { WeixinJSBridge.invoke(getBrandWCPayRequest, params, function (res) { // err_msg 是唯一可靠的判断依据不要看 err_code if (res.err_msg get_brand_wcpay_request:ok) { // 仅代表用户侧支付完成最终以服务端查单为准 location.replace(/pay/result?outTradeNo encodeURIComponent(params.outTradeNo)); } else if (res.err_msg get_brand_wcpay_request:cancel) { showTip(已取消支付); } else { // 失败原因可能是参数过期、openid 不匹配、签名不对 showTip(支付未完成请重试); reportFail(res.err_msg); // 上报方便排查 } }); }回调里的err_msg是唯一可靠判断err_code在部分场景下拿不到值。另外ok只代表用户在收银台点了确认不代表钱一定到账——银行侧扣款失败、通道超时都可能出现所以结果页必须回服务端查一次真实状态不能直接渲染「支付成功」。4.2 异步通知的验签与幂等真正的到账依据是异步通知。这个接口要独立、要能重复执行、要能在任何异常下都给对方一个明确回应。PostMapping(value /pay/notify, produces application/xml;charsetUTF-8) public String notify(RequestBody String body) { MapString, String data XmlUtils.parse(body); // 1. 验签把 sign 剔掉sign_type 保留参与计算 String expect SignUtils.sign(data, props.getApiKey(), data.getOrDefault(sign_type, MD5)); if (!expect.equals(data.get(sign))) { log.warn(回调验签失败, outTradeNo{}, data.get(out_trade_no)); return ack(FAIL, sign error); } // 2. 通信层失败直接回 SUCCESS避免无意义重推 if (!SUCCESS.equals(data.get(return_code))) { return ack(SUCCESS, OK); } // 3. 交业务层处理内部用条件更新做幂等 orderService.handlePaid( data.get(out_trade_no), data.get(transaction_id), Integer.parseInt(data.get(total_fee))); return ack(SUCCESS, OK); }ack返回的是标准报文return_code必须是SUCCESS否则平台会按策略重复推送短时间内打过来十几次如果你的处理逻辑不幂等库存能被扣穿。幂等的核心在那条带条件的 UPDATE 上UPDATE pay_order SET status PAID, transaction_id #{transactionId}, pay_time NOW() WHERE out_trade_no #{outTradeNo} AND status UNPAID AND amount_fen #{amountFen};返回的受影响行数等于 1说明这次是首次改单可以接着做发货、加积分、写流水等于 0说明要么已经处理过要么金额对不上前者直接返回成功后者必须告警。把金额也放进 WHERE 条件里是为了防「通知里的金额和订单金额不一致」这种情况哪怕概率极低也不能让它悄悄改单。4.3 主动查单兜底与结果页通知会丢这在真实环境里不是假设。用户网络切换、服务端短暂不可用、回调地址配置错一个字符都会导致通知永远到不了。所以必须有一条主动查单的兜底链路定时任务每分钟扫一批「UNPAID 且创建超过两分钟」的订单按单号去查真实状态查到已支付就走同一套handlePaid逻辑。Scheduled(fixedDelay 60_000) public void compensate() { // 只捞最近两小时的单再早的基本已经关单扫了也没意义 ListPayOrder list orderMapper.listPending(Duration.ofHours(2)); for (PayOrder o : list) { try { QueryResult r gateway.query(o.getOutTradeNo()); if (r.isPaid()) { // 复用同一套幂等逻辑不要另写一份 orderService.handlePaid(o.getOutTradeNo(), r.getTransactionId(), r.getAmountFen()); } else if (r.isClosed()) { orderMapper.closeIfUnpaid(o.getOutTradeNo()); } } catch (Exception e) { log.warn(查单失败 outTradeNo{}, o.getOutTradeNo(), e); } } }结果页也不要偷懒直接显示「支付成功」而是拿着单号回服务端查一次状态查到 PAID 才渲染成功页查不到就显示「支付处理中稍后刷新」同时前端做两三次短间隔轮询。用户看到「处理中」比看到错误的「成功」要能接受得多。5. 排查与避坑JSAPI 支付最常见的五类翻车现场这类问题的麻烦之处在于报错信息极度笼统同一个「签名错误」可能对应五六种原因。下面这五条是我在联调和线上都真实遇到过的按「现象 → 原因 → 解决」整理遇到问题时按顺序对一遍基本能定位到八九成。5.1 参数与签名类问题现象一下单接口返回签名错误本地跑通、服务器必挂。原因是字符集不统一。本地 JVM 默认编码可能是 UTF-8服务器是别的body里带中文时两边算出的摘要不同。解决办法是把getBytes()显式指定StandardCharsets.UTF_8同时在启动参数里加-Dfile.encodingUTF-8两处都做别只做一处。另外检查有没有字段传了空串却参与签名过滤逻辑要写在签名方法内部而不是靠调用方自觉。现象二前端调起直接失败提示参数错误。九成是package少了prepay_id前缀或者timeStamp用了毫秒。还有一种是服务端返回的字段名大小写不对appid和appId是两个东西。排查方式很土但有效把服务端返回的这组参数原样打印出来逐个字符跟文档比对别看个大概就过。现象三prepay_id明明刚拿到调起却说单号无效。预支付标识有效期两小时但如果你在服务端建单和前端调起之间隔了太久比如用户停留在确认页去泡了杯咖啡就会过期。另一种情况是同一个订单重复下单后一次生成的标识覆盖了前一次前端手里拿的是旧的那个。解决方式是下单接口做防重用 Redis 对outTradeNo加个短锁同时给前端参数加个时间戳校验超过 100 分钟的直接重新下单。5.2 调起与回调类问题现象四用户说付了钱订单还是未支付。先看通知有没有到。查日志关键字如果一条都没有说明回调地址不通——内网地址、非标端口、被网关拦了、HTTPS 证书链不完整都会导致推送失败。本地开发必须用端口映射工具把本地服务暴露到公网并且这个地址要能在浏览器里直接打开。如果通知到了但验签失败回去看 5.1 的第一条。如果通知到了、验签过了、但状态没改那就是幂等 SQL 的 WHERE 条件没匹配上把amount_fen和status打出来看。现象五库存被扣了两次甚至更多。通知重推 处理逻辑不幂等。平台在没收到SUCCESS回应时会按间隔重试如果你的接口因为业务异常抛了 500对方会认为失败继续推。解决办法是任何情况下都返回SUCCESS报文业务异常在内部记日志告警不要往外抛同时把状态更新做成带条件的原子操作靠受影响行数判断是否首次。注意联调阶段把回调原文、待签串、签名结果三段日志都打出来密钥记得脱敏这三段日志能解决掉八成以上的支付疑难杂症。上线后把待签串的日志级别降到 DEBUG避免密钥片段进日志文件。6. 二次开发把单商户配置改成多商户动态路由工程包默认是单商户配置一个appId对一套密钥。真要做 SaaS 或者多品牌商城第一个要动的就是这里。我的做法是把配置从application.yml挪到数据库表用「业务线标识 → 商户配置」做映射请求进来时通过请求头或者路径参数确定用哪套配置。改造分三步。第一步建配置表字段包含业务线标识、appId、mchId、密钥密文、证书路径、回调地址密钥用对称加密存启动时加载到本地缓存并定时刷新。第二步把PayProperties从单例改成上下文对象用ThreadLocal或者在方法入参里显式传递我更倾向显式传递——ThreadLocal在异步线程和线程池复用场景下漏值的问题排查起来能让人怀疑人生。第三步把PayGateway的方法签名全部加上配置参数业务层从配置中心拿到之后传进去。改造点涉及文件风险提示配置入库并加密PayProperties、新增MerchantConfigMapper密钥刷新要有兜底刷新失败继续用旧值回调地址按商户区分PayNotifyController回调路径里带业务线标识验签前先路由到对应密钥下单参数动态化PayGatewayImpl别把配置塞进静态变量多商户并发会串证书按商户加载HttpClientConfigp12 加载有开销做本地缓存并设上限验证方法很简单但必须走用两个不同商户的账号各下一单交叉验证——A 商户的订单用 B 的密钥去查必须查不到A 的回调打到 B 的路径必须验签失败。这一步跑通了多商户改造才算落地。还有个小技巧值得单独说给每条支付链路的日志加一个统一的追踪标识从下单一直贯穿到回调落账。出问题时 grep 一个标识就能把整条链路串起来比在几个服务里按时间戳翻日志高效得多。从那以后我每次接新的支付通道都强制先把这条追踪链路和幂等 SQL 写完再写业务宁可多花半天也不想再经历一次对账到凌晨的滋味。希望帮到你。本文还有配套的精品资源点击获取