简介面向需要实现H5页面微信支付的Java后端工程师这份压缩包以小型可运行工程的方式展示从后台统一下单、获取prepay_id、生成支付链接到前端调起微信支付、异步回调验签、订单查询与异常重试的完整闭环。整个资源包仅16KB共11个文件其中5个Java源文件承担统一下单与回调逻辑3个XML配置用于工程配置另附properties配置文件、1份“部署必看”文档和HTML测试页面目录结构简洁适合作为二次开发起点或内部培训案例。已有1319人下载学习反映出企业对H5支付接入的普遍需求。阅读并运行示例后可深入理解微信支付接口参数规范、签名加密与回调安全校验逻辑同时能参考其中已包含的异常处理与重试策略直接复用代码以缩短对接微信官方文档时的排查时间并规避常见疏漏。1. Java 后端接 H5 微信支付先分清两条链路再动手同样叫 H5 微信支付公众号内嵌页和微信外部浏览器是两条完全不同的接入链路前者走 JSAPI 支付必须拿到用户 openid后者走 H5 Native 支付靠场景信息和域名校验拉起收银台。很多 Java 开发者第一步就选错下单接口能调通页面却无论如何也调不起支付这类问题在联调阶段能占到六成以上。这篇文章把 Java 后端接 H5 微信支付要做的事拆开讲透商户号与密钥体系怎么准备、两种下单接口的差异、请求签名和页面调起参数怎么生成、回调验签与幂等更新怎么写以及最容易翻车的那几个坑。如果你是正在接支付功能、被签名和回调折磨的 Java 服务端开发者按顺序走一遍就能把两条链路都跑通也顺带搞清楚微信支付 APIv3 的整体套路。2. 接入前准备商户号、AppID、APIv3 密钥与证书体系2.1 JSAPI 与 H5 Native两种 H5 支付场景的选型差异先把场景选对后面的代码才有意义。JSAPI 支付也叫公众号支付用户必须停留在微信内置浏览器里通过网页授权拿到 openid 后下单前端再用微信提供的 JS-Bridge 直接拉起支付面板。H5 Native 支付面向的是微信外部的手机浏览器或第三方 App 内 WebView用户通过短信链接、分享卡片等方式进入 H5 页面后端下单后返回一个 h5_url前端直接把页面重定向过去收银台。对比项公众号内 H5JSAPI外部浏览器 H5Native用户环境微信内置浏览器手机自带浏览器、其他 App WebView是否必须 openid是否下单接口/v3/pay/transactions/jsapi/v3/pay/transactions/h5调起方式返回 prepay_id页面内 JS-Bridge 拉起返回 h5_url直接跳转典型入口公众号菜单、图文消息、扫码短信链接、广告页、App 内 H5选错的后果很直接把 JSAPI 下单的链接拿到微信外打开会提示当前环境不支持调起支付反过来把 H5 下单地址丢进公众号内打开跳到一个普通浏览器风格的收银台体验割裂部分 Android 微信还会拦截跳转。所以接到需求时先问清楚用户从哪里进页面再决定走哪条路。2.2 密钥与证书APIv3 密钥、商户私钥、平台证书各管什么微信支付 APIv3 的密钥体系有四个东西很多人栽在把它们混为一谈。商户 API 证书包含apiclient_key.pem商户私钥和apiclient_cert.pem商户证书商户私钥用于对所有请求做签名商户证书本身在常规业务里几乎用不到。APIv3 密钥是你自己在商户平台设置的一串 32 字节字符串它不参与请求签名专门用于解密微信支付回调里的加密数据。微信支付平台证书则是用来验证回调通知是否真的来自微信支付的公钥证书需要通过接口动态获取并定时更新。加载商户私钥是第一个要写的代码注意apiclient_key.pem是 PKCS#8 格式常见做法是读文件、去头尾、Base64 解码后交给KeyFactory生成私钥对象private PrivateKey loadPrivateKey(String privateKeyPath) { try (BufferedReader reader Files.newBufferedReader( Paths.get(privateKeyPath), StandardCharsets.UTF_8)) { StringBuilder keyContent new StringBuilder(); String line; while ((line reader.readLine()) ! null) { if (!line.contains(-----BEGIN PRIVATE KEY-----) !line.contains(-----END PRIVATE KEY-----)) { keyContent.append(line); } } byte[] keyBytes Base64.getDecoder().decode(keyContent.toString().trim()); return KeyFactory.getInstance(RSA) .generatePrivate(new PKCS8EncodedKeySpec(keyBytes)); } catch (Exception e) { throw new IllegalStateException(加载商户私钥失败, e); } }这段代码把 PEM 文件里的头尾标记行去掉剩余内容按 Base64 解码成字节数组再按 PKCS#8 规范解析成 RSA 私钥。很多人在第一步就踩坑从某些旧工具导出的私钥可能是 PKCS#1 格式直接按 PKCS#8 解析会报InvalidKeySpecException。如果你手里的 pem 文件头是BEGIN RSA PRIVATE KEY需要先做格式转换。2.3 调试环境准备回调地址、测试商户号与日志微信支付要求回调地址必须是公网可访问的 HTTPS 地址这一点没有商量余地。本地联调的常见做法是准备一台测试服务器把 Java 服务部署上去前面挂一层 Nginx 终结 HTTPS再把回调路径反代到应用端口。下单接口和回调入口的日志必须打全请求体、响应体、签名串、回调原文一样都不能省后面排查问题全靠这些记录。商户号与 AppID 的绑定关系需要在商户平台配置改完不是立刻生效存在同步延迟。测试阶段我习惯用真实的 0.01 元小额支付验证全链路微信支付没有面向开发者的沙箱环境一切以真实请求为准。下单前建议给每个订单生成唯一业务号out_trade_no同一商户号下重复提交相同单号会直接被平台拒绝。可以把这个日志过滤器理解为支付模块的地基地基没打好后面所有问题都会变成黑匣子。3. Java 实现下单接口签名构造、参数映射与页面调起3.1 构造签名请求Authorization 头的生成逻辑微信支付 APIv3 的每个请求都要带Authorization头签名算法是WECHATPAY2-SHA256-RSA2048。签名串的拼接规则固定HTTP 方法、URL 路径、请求时间戳、随机字符串、请求体五个元素中间用换行符连接末尾还要有一个换行。URL 路径是去掉域名后的部分例如/v3/pay/transactions/jsapi如果路径带 query 也要完整带上。private HttpClient buildHttpClient() { return HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); } private String sendOrderRequest(String urlPath, String bodyJson, PrivateKey privateKey, String mchId, String serialNo) throws Exception { String timestamp String.valueOf(Instant.now().getEpochSecond()); String nonce UUID.randomUUID().toString().replace(-, ).substring(0, 16); String message POST\n urlPath \n timestamp \n nonce \n bodyJson \n; Signature signer Signature.getInstance(SHA256withRSA); signer.initSign(privateKey); signer.update(message.getBytes(StandardCharsets.UTF_8)); String signature Base64.getEncoder().encodeToString(signer.sign()); String authHeader WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonce \, timestamp\ timestamp \, serial_no\ serialNo \, signature\ signature \; HttpRequest request HttpRequest.newBuilder() .uri(URI.create(https://api.mch.weixin.qq.com urlPath)) .header(Authorization, authHeader) .header(Content-Type, application/json) .header(Accept, application/json) .POST(HttpRequest.BodyPublishers.ofString(bodyJson, StandardCharsets.UTF_8)) .build(); HttpResponseString response buildHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); return response.body(); }serial_no是商户 API 证书的序列号在商户平台下载证书时能看到写死在配置里即可。这里必须强调一点签名用的bodyJson必须是最终发送的请求体原样如果框架在发送前对 JSON 做了重新序列化、字段排序变化或者转义处理签名就会失败。所以最稳妥的做法是先把请求体字符串固定下来签名后再直接作为 POST body 发送中间不要再经过任何对象到 JSON 的转换。3.2 JSAPI 下单openid、金额单位与幂等键JSAPI 下单的请求体包含 appid、mchid、description、out_trade_no、notify_url、amount 和 payer 几个部分。其中金额单位是分必须传整数后端如果用元做存储转换时不要用浮点数直接乘 100而是用BigDecimal.movePointRight(2)保证精度。payer 里的 openid 来自公众号网页授权走snsapi_base静默授权即可拿到不需要用户主动确认。MapString, Object body new LinkedHashMap(); body.put(appid, appId); body.put(mchid, mchId); body.put(description, order.getTitle()); body.put(out_trade_no, order.getOutTradeNo()); body.put(notify_url, notifyUrl); MapString, Object amount new LinkedHashMap(); amount.put(total, new BigDecimal(order.getAmountYuan()) .movePointRight(2).intValue()); amount.put(currency, CNY); body.put(amount, amount); MapString, Object payer new LinkedHashMap(); payer.put(openid, openId); body.put(payer, payer); String responseBody sendOrderRequest(/v3/pay/transactions/jsapi, toJson(body), privateKey, mchId, serialNo);响应里最核心的字段是prepay_id前端拉起支付面板时需要把它拼到package参数里。拿到 prepay_id 之后还不能直接交给前端需要再生成一个 paySign这个签名的拼接规则和请求签名完全不同注意区分String payMessage appId \n timeStamp \n nonceStr \nprepay_id prepayId \nRSA\n; String paySign signWithRsa(payMessage, privateKey);paySign 的签名串由 appId、timeStamp、nonceStr、package值为prepay_idxxx、signType 组成signType 固定传RSA同样用商户私钥签名。前端拿到这五个参数后通过微信内置的 JS-Bridge 调起支付回调里判断err_msg是否为get_brand_wcpay_request:ok但注意这只代表前端支付流程结束业务上是否真的到账必须以后端回调为准。function invokeWxPay(payParams) { if (typeof WeixinJSBridge undefined) { alert(当前环境不支持微信支付); return; } WeixinJSBridge.invoke(getBrandWCPayRequest, { appId: payParams.appId, timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, // 形如 prepay_idwx... signType: RSA, paySign: payParams.paySign }, function (res) { if (res.err_msg get_brand_wcpay_request:ok) { // 提示用户支付成功但发货以后端回调为准 } }); }注意前端支付成功提示和真实到账之间存在时间差不要在前端回调里直接发权益、加积分这些操作必须在后端收到微信支付回调后才能执行。3.3 H5 下单与调起场景信息与页面跳转H5 Native 下单接口的请求体与 JSAPI 大同小异差别在于不需要 payer.openid但必须携带scene_info里面包含客户端真实 IP 和 h5_info 类型。h5_info.type 在普通网页场景下固定填Wap如果有 iOS、Android 环境区分也可以对应填写。这里有个容易被忽略的点payer_client_ip必须是用户客户端的真实 IP不能用服务器出口 IP 代替否则会被平台风控直接拒单。MapString, Object sceneInfo new LinkedHashMap(); sceneInfo.put(payer_client_ip, request.getClientIp()); MapString, Object h5Info new LinkedHashMap(); h5Info.put(type, Wap); sceneInfo.put(h5_info, h5Info); body.put(scene_info, sceneInfo); String responseBody sendOrderRequest(/v3/pay/transactions/h5, toJson(body), privateKey, mchId, serialNo);响应里返回的是h5_url一个微信支付的收银台跳转链接有效期通常只有几分钟。后端拿到后返回给前端前端直接用window.location.href或创建a标签点击跳转不建议用window.open部分手机浏览器会拦截弹窗导致收银台打不开。window.location.href h5Url;H5 支付对域名有硬性要求发起支付的页面域名必须在商户平台完成配置且该域名已经完成 ICP 备案备案主体需要与商户号主体一致。这个配置不提前做好下单接口会返回域名不匹配之类的错误而且配置生效存在延迟改完别急着立刻测试。4. 支付回调通知验签、AES-256-GCM 解密与幂等更新4.1 回调通知入口与基础校验支付成功后微信支付服务器会向notify_url发送 POST 请求请求头里带Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial四个字段。Controller 入口要做的第一件事不是解析业务数据而是用Wechatpay-Serial找到本地对应的微信支付平台证书然后验证签名是否合法。PostMapping(/pay/wechat/notify) public ResponseEntityString notify( RequestBody String body, RequestHeader(Wechatpay-Timestamp) String timestamp, RequestHeader(Wechatpay-Nonce) String nonce, RequestHeader(Wechatpay-Signature) String signature, RequestHeader(Wechatpay-Serial) String serial) { X509Certificate certificate platformCertService.getBySerial(serial); if (certificate null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(buildFailResponse()); } String message timestamp \n nonce \n body \n; boolean verified verifyWithPlatformCert(message, signature, certificate); if (!verified) { log.warn(支付回调验签失败, serial{}, body{}, serial, body); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(buildFailResponse()); } // 验签通过进入解密与业务处理 return handleNotifyBody(body); }验签失败时返回 401微信会按照失败策略重新推送。需要注意回调成功返回必须是微信规定的固定 JSON 结构不能随便返回一个成功字符串。处理失败时建议返回 4xx 或 5xx让平台继续重试而不是自己捕获异常后返回成功否则订单就永远停在那里了。4.2 验签与解密从平台证书到明文订单验签签名串的拼接规则是 timestamp、nonce、body 三个值加换行最后再补一个换行用微信支付平台证书的公钥做 SHA256withRSA 验签。平台证书不是一成不变的微信支付会定期轮换所以需要定时从证书下载接口拉取并缓存按序列号区分新旧证书。很多项目上线几个月后突然回调全部失败原因就是证书更新了而本地缓存还是旧的。验签通过后请求体里的resource对象包含密文数据需要先做 AES-256-GCM 解密才能拿到真实订单内容。解密时用到的密钥是 APIv3 密钥nonce 直接取回调报文里的 nonceassociated_data 取回调报文里的 associated_data顺序不能反private byte[] aesGcmDecrypt(byte[] apiV3Key, String nonce, String ciphertext, String associatedData) throws Exception { Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec keySpec new SecretKeySpec(apiV3Key, AES); GCMParameterSpec gcmSpec new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); if (associatedData ! null !associatedData.isEmpty()) { cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); } return cipher.doFinal(Base64.getDecoder().decode(ciphertext)); }解密后的明文 JSON 包含out_trade_no、transaction_id、trade_state、success_time、amount等字段。其中trade_state只有等于SUCCESS时才需要更新业务状态其他状态如USERPAYING、CLOSED直接忽略或只记录日志。整个解密过程对新人来说像玄学但本质就是一把密钥、一个固定算法多写几个单元测试就能消除恐惧感。4.3 幂等控制与失败重试回调通知存在重复推送的可能微信支付的文档明确写了通知可能多次到达。幂等控制要做两层第一层判断订单状态是否已经变为支付成功如果是就直接返回成功不再处理第二层用数据库乐观锁兜底防止并发场景下两个线程都读到未支付状态然后各自往下执行。Transactional public void handleTransaction(TransactionMessage msg) { Order order orderMapper.selectByOutTradeNo(msg.getOutTradeNo()); if (order null) { throw new BizException(订单不存在: msg.getOutTradeNo()); } if (PAID.equals(order.getStatus())) { log.info(重复回调, 忽略处理: {}, order.getOutTradeNo()); return; } int updated orderMapper.markPaid(order.getId(), order.getStatus()); if (updated ! 1) { // 并发下已被其他线程更新本次直接返回 return; } // 只有这里返回成功才真正发放权益 benefitService.grant(order); }markPaid的 SQL 类似update orders set statusPAID where id? and statusCREATED影响行数为 1 说明当前线程拿到了处理权为 0 说明别人已经改过状态。这个方案比分布式锁可靠得多不依赖锁的过期时间也不会出现锁误删导致的重复发货。如果发货逻辑很重比如调外部接口、发优惠券建议把benefitService.grant放到消息队列里异步执行回调只负责落库变更保住最核心的订单状态。5. H5 微信支付常见问题排查签名、回调与浏览器兼容5.1 签名报错商户私钥格式与换行符的坑现象调用下单接口返回签名错误具体错误信息指向signature字段。这是接入过程中出现频率最高的问题没有之一。原因往往出在两个地方一是商户私钥文件格式不对前面说过BEGIN RSA PRIVATE KEY与BEGIN PRIVATE KEY的区别二是签名串末尾的换行符丢了或者签名后请求体又被框架格式化了一遍导致签名和实际发送内容不一致。解决思路是把签名过程从黑匣子变成白盒。我在代码里加一个 debug 开关打印完整的签名串、签名结果和实际发送的请求体三样东西放在同一条日志里对比一下立刻能看出来是哪个环节出了问题。血的教训是永远不要相信框架帮你重组的 JSON请求体字符串在签名前就要固定下来。5.2 下单被拒AppID 与商户号绑定、H5 域名校验现象下单接口返回当前商户未接入或者 H5 下单报商家参数格式有误、支付目录未配置。这类问题在测试环境改完配置后尤其常见。原因是 AppID 和商户号在商户平台没有完成绑定或者绑定关系刚生效还没同步到支付链路H5 场景下则是支付域名没配置或域名备案主体与商户主体不一致。解决这类问题没有捷径只能打开商户平台逐项核对产品中心里的 AppID 绑定列表、开发配置里的支付目录和 H5 支付域名、APIv3 密钥是否重置过。任何一项改动后都要等几分钟再测试支付平台配置同步有延迟不等同步完成就反复测只会把自己绕晕。返回的错误信息里如果带sub_code或detail字段优先看这些字段。5.3 支付成功但业务未更新回调丢失与并发现象用户微信账单里显示扣款成功后台订单状态还是待支付用户投诉到客服。原因分三种回调地址不可达微信推了几次后放弃回调处理代码里验签没通过被自己拒绝了并发场景下两个线程同时处理同一笔订单状态更新互相覆盖。每次排查这类问题我都会先说一句话先找回调请求的原始日志再谈其他。回调入口必须记录完整请求头和报文体这是第一手证据。如果一条回调日志都没有就是回调地址或网络链路的问题如果有日志但验签失败检查平台证书是否更新如果日志显示处理成功但业务没变检查乐观锁 SQL 是不是写错了条件。最后再用一个定时对账任务兜底每天拉取微信账单跟本地订单对比差异单自动告警。5.4 部分浏览器调不起支付环境与 UA 差异现象同一个 H5 链接在微信内置浏览器里能正常拉起收银台在手机自带浏览器里打不开或者提示当前浏览器不支持。原因在于 JSAPI 和 H5 Native 的适用环境不同JSAPI 只能在微信环境里用H5 Native 虽然有独立的收银台页面但部分安卓 WebView 内核过旧跳转表现不一致。前端拿到 h5_url 之后跳转方式会影响成功率。window.open在部分浏览器会被弹窗拦截换成a标签加用户点击触发跳转成功率会明显提升。测试时要覆盖微信、手机自带浏览器、主流 App 的 WebView 三种环境不要只在微信里测一次就上线。另外如果用户从 App 内 WebView 打开 H5需要确认该 App 的 WebView 是否允许外部浏览器跳转有些客户端会拦截所有跳转到系统浏览器的行为。6. 最后一个技巧把支付回调做成状态机驱动的幂等组件当项目里同时存在公众号、小程序、App、H5 多种支付场景时回调处理逻辑会迅速膨胀堆在同一个 Controller 里会越来越难维护。我更推荐的做法是把它拆成四层验签解密层、消息路由层、状态机校验层、业务执行层。验签解密只做一次然后根据解密出来的out_trade_no和当前订单状态决定走哪条处理分支。当前状态触发事件目标状态CREATED回调 trade_stateSUCCESSPAIDUSERPAYING回调 trade_stateUSERPAYINGUSERPAYINGPAID退款成功回调REFUNDEDCLOSED超时关闭CLOSED业务处理器可以抽象成一个接口不同支付场景各自实现互不干扰public interface PaymentHandler { String supportType(); boolean handle(NotifyMessage message); }验证这套组件是否可靠有一个笨办法把回调报文保存成 JSON 文件写一个回放工具同一个报文连续跑三遍确认订单只被更新一次再模拟两个线程同时消费同一笔回调观察数据库里是否只有一条状态变更记录。这两关过了支付回调这块就基本稳固了。以前我吃过一个亏回调处理器里直接执行发权益逻辑结果数据库连接池被打满回调一直失败重试用户付了款拿不到东西客服电话被打爆。从那以后我养成了习惯回调处理只落订单状态重活全部丢队列靠每日对账保证最终一致。这个习惯帮我躲过了很多线上事故希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?