简介围绕 Java 后端接入 H5 微信支付的实战代码包面向需要在小程序之外的移动网页、电商站点中完成下单与收款的初中级 Java 开发者。内容从统一下单接口出发串起预支付会话标识获取、支付链接与二维码生成、前端调起微信支付控件、异步回调验签与订单状态更新并补充订单查询防漏单、网络异常重试与超时控制、HTTPS 传输与敏感信息加密等合规要点测试环境与正式环境的切换也有交代。压缩包共 11 个文件、约 16KB以 5 个 Java 源码为主体配合 3 个 XML 配置、1 个 properties 参数文件、1 个 HTML 页面和 1 份部署说明文档体量轻巧便于对照梳理调用链路。已有 1322 人学习下载。读者可据此搭起可运行的支付骨架理解签名生成与回调校验的落地写法并借助文档快速完成环境配置与联调排错。1. H5 支付这条链路为什么 Java 后端要单独包一层 MWEB 下单很多人第一次接入 Java 微信支付会把 H5 微信支付当成“网页版扫码支付”下单接口调通了h5_url也拿到了用户点开却停在“请在外部浏览器中打开”的提示页又或者回调明明收到了订单状态却一直是待支付。这套源码包把 H5 微信支付拆成三层来写Java 侧的下单与 V3 签名、回调解密与幂等落库、前端唤起与结果页兜底代码可以直接放进 Spring Boot 工程里跑。它适合两类人已经能写普通下单、但对签名串拼接、平台证书验签、AEAD 解密心里没底的后端以及需要在两天内把一个 H5 收银台完整链路搭起来的小团队。读下去你能判断清楚这份 Java 微信支付源码里哪部分拿去就能用哪部分必须按自己的商户配置改。2. 下单与签名MWEB 接口的参数表、签名串和 Java 落地把 H5 支付跑通难点不在“调接口”而在调之前那几行字符串。签名串少一个\n、URL 少一段 query、请求体被序列化框架重新排过一次序返回的都是同一个 401报错信息还都一样排查起来极为费劲。2.1 为什么 H5 支付走 /v3/pay/transactions/h5 而不是 JSAPI微信支付的下单接口按支付场景分了几个入口选错了后面全错场景接口路径前提条件典型触发环境扫码支付 Native/v3/pay/transactions/native无PC 浏览器展示二维码公众号/服务窗 JSAPI/v3/pay/transactions/jsapi必须有用户openid且 appid 与商户号已绑定微信内置浏览器H5 支付 MWEB/v3/pay/transactions/h5商户号开通 H5 支付配置 H5 支付域名手机外部浏览器Safari、Chrome 等小程序支付/v3/pay/transactions/jsapi小程序 appid openid小程序内关键区别在“谁来唤起支付”。JSAPI 返回的是prepay_id加一串前端签名参数由微信 JS-SDK 在微信内置浏览器里唤起收银台H5 支付返回的是一个h5_url它本质上是一个跳转链接用户被带到微信的中间页再唤起微信客户端完成付款。因此 H5 支付拿不到openid也能下单这是它相对 JSAPI 唯一但很实用的优势。反过来也要认清边界H5 支付在微信内置浏览器里打不开会提示让用户去外部浏览器。如果你的主流量入口是公众号菜单或者聊天里分享的链接那用户九成在微信内这时候正确做法是 JSAPIH5 支付只能作为降级兜底。这套源码包把两种场景的判断逻辑放在同一层网关里先判 UA 再决定走哪个接口这个设计比自己拍脑袋选一个要稳。2.2 下单参数哪些必填哪些是最容易填错的地方下单请求体是 JSON字段不多但每一个都有明确的坑位参数必填格式/取值实际踩过的坑appid是字符串填了公众号 appid但商户号没和它绑定报appid 与 mchid 不匹配mchid是字符串不要转数字转成 long 后前导零丢失签名对但下单失败description是最长 127 字符塞了商品标题全文超长被截断后对账对不上out_trade_no是6–32 位同一商户号下唯一用自增 ID 且跨环境共用测试单号污染生产time_expire否RFC3339如2024-06-01T12:00:0008:00时区写成Z实际比预期早 8 小时关闭notify_url是必须 https不能带 query 参数带了?fromwx微信直接拒绝amount.total是整数单位分传了元1 元变 1 分amount.currency否固定CNY不传默认 CNY传错币种直接报错scene_info.payer_client_ip是用户真实公网 IP拿的是 Nginx 内网 IP 或网关 IPscene_info.h5_info.type是固定Wap首字母大写写成wap或H5报参数错误scene_info.h5_info.app_name否应用名建议填微信中间页会展示scene_info.h5_info.app_url是网站域名与商户平台配置的 H5 支付域名不一致下单被拒payer_client_ip这个字段值得单独说。生产环境一般有 SLB 或 Nginxrequest.getRemoteAddr()拿到的是内网地址微信侧会拒绝或者风控打分异常。常见做法是从X-Forwarded-For里取第一个非内网地址并且要在最外层网关做一次白名单校验防止用户伪造请求头。// 取真实客户端 IP优先 X-Forwarded-For 第一个非内网地址 public static String realIp(HttpServletRequest request) { String xff request.getHeader(X-Forwarded-For); if (StringUtils.isNotBlank(xff)) { for (String ip : xff.split(,)) { String candidate ip.trim(); // 跳过内网段避免拿到网关地址 if (!isPrivate(candidate)) { return candidate; } } } return request.getRemoteAddr(); }这段逻辑的意义不在于“能取到 IP”而在于取到一个微信侧认可的公网 IP。如果你们前面是多层代理X-Forwarded-For会是逗号分隔的一串最左边是客户端往右是各级代理直接取整串或者取最后一个都会出问题。2.3 签名串五行字符串一个换行都不能错V3 接口的签名用的是商户 API 私钥做 SHA256withRSA签名串固定五行每行以\n结尾最后一行后面也必须有一个\nHTTP请求方法\n URL含 query不含协议和域名\n 时间戳秒级\n 随机串\n 请求报文主体\n这里有两个高频翻车点。一是 URL 必须包含 query string查单接口GET /v3/pay/transactions/out-trade-no/xxx?mchidyyy如果把?mchidyyy漏掉签名必错。二是请求体如果是GET主体为空但那一行\n不能省很多人写成四行报错和五行写错完全一样。public final class WxPayV3Signer { private final PrivateKey merchantPrivateKey; // PKCS#8 格式的商户 API 私钥 private final String mchId; private final String serialNo; // 商户 API 证书序列号 /** * param method GET / POST * param urlPath 以 / 开头含 query例如 /v3/pay/transactions/h5 * param body GET 传空字符串不能传 null */ public String buildAuthorization(String method, String urlPath, String body) { long timestamp System.currentTimeMillis() / 1000; String nonce RandomStringUtils.randomAlphanumeric(32); String message method \n urlPath \n timestamp \n nonce \n body \n; String signature sign(message); return WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonce \, signature\ signature \, timestamp\ timestamp \, serial_no\ serialNo \; } private String sign(String message) { try { Signature signer Signature.getInstance(SHA256withRSA); signer.initSign(merchantPrivateKey); signer.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(signer.sign()); } catch (GeneralSecurityException e) { throw new IllegalStateException(微信支付签名失败, e); } } }serial_no是商户 API 证书的序列号不是平台证书序列号这两个经常被搞混。商户证书序列号在你申请证书时就有平台证书序列号是微信那边的用于验签响应。body必须和真正发出去的字节完全一致如果你用的是Map拼参数签名前先序列化一次发请求时复用同一个字符串千万别签一遍、发一遍各自序列化。2.4 完整的下单方法与响应处理把参数拼装、签名、发请求串起来一个可用的 Java 服务方法大概长这样public H5OrderResult createH5Order(PayOrder order, String clientIp) { MapString, Object body new LinkedHashMap(); body.put(appid, props.getAppId()); body.put(mchid, props.getMchId()); body.put(description, StringUtils.abbreviate(order.getSubject(), 120)); body.put(out_trade_no, order.getOutTradeNo()); body.put(notify_url, props.getNotifyUrl()); body.put(time_expire, order.getExpireAt() .format(DateTimeFormatter.ofPattern(yyyy-MM-ddTHH:mm:ssXXX))); body.put(amount, Map.of(total, order.getAmountFen(), currency, CNY)); body.put(scene_info, Map.of( payer_client_ip, clientIp, h5_info, Map.of( type, Wap, app_name, props.getAppName(), app_url, props.getAppUrl() ) )); String json JSON.toJSONString(body); // 只序列化一次签名和发送复用 String path /v3/pay/transactions/h5; String auth signer.buildAuthorization(POST, path, json); HttpResponseString resp http.send(path, json, auth); if (resp.statusCode() ! 200) { // 微信的错误体是 JSONcode / message / detail一定要打到日志里 throw new WxPayException(resp.statusCode(), resp.body()); } // 响应也要验签这里交给统一拦截器处理 return JSON.parseObject(resp.body(), H5OrderResult.class); }返回体里最有用的字段是h5_url有效期只有 5 分钟过期后用户再点就废了。所以正确做法是用户点“去支付”时才实时下单不要把h5_url存库然后定时推送那种设计一定会碰到“链接已失效”。响应验签容易被忽略。微信返回的报文带Wechatpay-Serial、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature四个响应头验签串是“时间戳\n随机串\n响应体\n”。如果不验签理论上中间人可以把h5_url换成自己的这类问题在内部系统里不致命但对外收银台属于必须做的一环。验签用的证书有两种模式平台证书模式按Wechatpay-Serial去本地证书缓存里找对应公钥公钥模式则是Wechatpay-Serial直接就是公钥 ID。这两种模式配置项不一样迁移时最容易漏改。3. 回调、查单与幂等把“支付成功”这件事做扎实下单成功只是开始。用户付完钱钱到账是微信的事订单状态翻不翻得过来是你的事。这一层做不好用户付了钱订单还是待支付客服工单马上就来。3.1 异步通知解密AEAD_AES_256_GCM 的三个参数微信的回调报文长这样真正的订单信息在resource里而且是加密的{ id: EV-2024060100001, event_type: TRANSACTION.SUCCESS, resource: { algorithm: AEAD_AES_256_GCM, ciphertext: base64..., associated_data: transaction, nonce: 12位随机串, original_type: transaction } }解密用的是 APIv3 密钥也就是你在商户平台自己设置的那串 32 位字符不是 API 密钥、不是商户号、不是证书密码。三个参数缺一不可nonce是 GCM 的 IV长度 12 字节associated_data是附加认证数据交易类通知固定为transactionciphertext是密文末尾自带 16 字节认证标签所以GCMParameterSpec的 tag 长度写 128。public String decryptResource(String ciphertext, String nonce, String associatedData) { try { Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec key new SecretKeySpec( props.getApiV3Key().getBytes(StandardCharsets.UTF_8), AES); // tag 长度 128 bitnonce 长度 12 字节这两个写错就是 AEADBadTagException GCMParameterSpec spec new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, key, spec); cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); byte[] plain cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(plain, StandardCharsets.UTF_8); } catch (GeneralSecurityException e) { throw new WxPayException(回调报文解密失败, e); } }解密之后拿到的 JSON 里有out_trade_no、transaction_id、trade_state、amount.total、payer.openid等字段。其中amount.total必须和你自己库里的金额再比一次单位是分。这一步是防“金额被篡改”的最后一道闸虽然报文是加密的但业务侧对一遍账没坏处。3.2 幂等微信的重试机制决定了你不能只写一次微信在没收到成功应答时会重试重试间隔从 15 秒开始逐步拉长最长跨度接近 24 小时累计十几次。也就是说同一笔订单的回调你的接口很可能收到多遍而且可能在你发货之后才来第二遍。正确的表结构要给状态机留字段而不是只有一个statusCREATE TABLE pay_order ( out_trade_no VARCHAR(32) NOT NULL PRIMARY KEY, transaction_id VARCHAR(64) DEFAULT NULL COMMENT 微信支付订单号, amount_fen BIGINT NOT NULL COMMENT 单位分, status VARCHAR(16) NOT NULL COMMENT WAIT/PAYING/SUCCESS/CLOSED/REFUND, paid_at DATETIME DEFAULT NULL, notify_count INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_transaction (transaction_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;uk_transaction这个唯一索引是第二道保险万一out_trade_no的幂等没兜住transaction_id重复插入会直接失败至少不会出现两条成功记录。业务侧的状态推进用带条件的更新而不是先查后改Transactional(rollbackFor Exception.class) public boolean markPaid(String outTradeNo, String transactionId, long paidFen) { // 只有从 PAYING 才能推 SUCCESS重复回调时影响行数为 0直接返回 int rows jdbc.update( UPDATE pay_order SET statusSUCCESS, transaction_id?, paid_atNOW(), notify_countnotify_count1, versionversion1 WHERE out_trade_no? AND statusPAYING AND amount_fen?, transactionId, outTradeNo, paidFen); if (rows 0) { // 已经成功过或者金额对不上记一条告警日志就好 log.warn(重复回调或金额不一致 outTradeNo{}, fen{}, outTradeNo, paidFen); return false; } // 发货、加积分、发消息都在同一个事务里或者走本地消息表 deliveryService.deliver(outTradeNo); return true; }这段代码的关键在WHERE statusPAYING。它等价于一个乐观锁让并发进来的两次回调里只有一次能更新成功另一次影响行数为 0天然幂等。比“先select判断状态再update”靠谱得多后者在并发下必然出问题。回调接口的应答也很讲究处理成功必须返回 HTTP 200 或 204不要返回{code:SUCCESS}这种业务包装体微信只看 HTTP 状态码。返回 500 或者 200 但带错误信息都会被判定为失败并触发重试你这边就会收到十几遍同样的通知。3.3 主动查单回调丢失时的补漏机制回调会丢这是常态不是异常。网关限流、发布重启、证书过期、公网抖动任何一个环节都能让通知打不进来。所以必须有一个主动查单的补偿任务扫那些停留在PAYING太久的订单去微信那边问一句。Scheduled(fixedDelay 60_000) public void compensate() { // 只捞 2 分钟前创建、仍处于 PAYING 的单避免和正常回调打架 ListPayOrder pending orderMapper.selectPending( LocalDateTime.now().minusMinutes(2), 100); for (PayOrder order : pending) { String path /v3/pay/transactions/out-trade-no/ order.getOutTradeNo() ?mchid props.getMchId(); String auth signer.buildAuthorization(GET, path, ); HttpResponseString resp http.get(path, auth); if (resp.statusCode() ! 200) { continue; // 查不到就下一轮再来别在这里抛异常打断整批 } JSONObject body JSON.parseObject(resp.body()); String state body.getString(trade_state); if (SUCCESS.equals(state)) { markPaid(order.getOutTradeNo(), body.getString(transaction_id), body.getJSONObject(amount).getLongValue(total)); } else if (CLOSED.equals(state) || REVOKED.equals(state)) { orderMapper.close(order.getOutTradeNo()); } } }查单接口的签名串里 URL 必须带?mchidxxx这是前面提过的高频错误。另外trade_state不止SUCCESS一种还有NOTPAY、CLOSED、REFUND、PAYERROR只判成功不判关闭会导致大量订单永远挂在中间态。4. 前端唤起与结果页h5_url 拼接、域名白名单和浏览器判定后端把h5_url返给前端事情只做了一半。用户从点击到看到“支付成功”中间隔着跳转、唤起、返回、轮询四道关任何一道断了都会变成“我付了但没显示成功”。4.1 redirect_url 的编码规则与域名白名单h5_url本身是一个完整的跳转链接你需要在后面追加redirect_url参数告诉微信支付完成后把人送回哪里public String buildRedirectUrl(String h5Url, String returnUrl) { // redirect_url 必须整体 urlencode否则里面的 ? 和 会把外层参数截断 String encoded URLEncoder.encode(returnUrl, StandardCharsets.UTF_8); String separator h5Url.contains(?) ? : ?; return h5Url separator redirect_url encoded; }两个约束必须记住。第一returnUrl必须是公网可访问的 https 地址本机localhost和测试环境的http://192.168.x.x都不行微信那边根本跳不过去。第二returnUrl的域名要和商户平台里配置的 H5 支付域名一致否则支付完成后跳转会被拦。本地联调的常见做法是配置一个内网穿透域名并加进白名单同时准备一套测试商户号别拿生产商户号试。redirect_url回来后URL 上会带一些参数但不要依赖这些参数判断支付结果。用户的网络环境、是否手动改地址、是否中途关页面都能让参数丢失。正确姿势是回来之后拿自己的out_trade_no去后端查状态。4.2 微信内置浏览器的判定与降级H5 支付在微信内打不开这一点必须在点击“去支付”之前就判断掉而不是等用户跳过去看到提示再回来function isWeChatBrowser() { return /MicroMessenger/i.test(navigator.userAgent); } async function goPay(orderNo) { if (isWeChatBrowser()) { // 微信内走 JSAPI先拿 openid再调 jsapi 下单拿 prepay_id const cfg await fetch(/api/pay/jsapi/config?outTradeNo${orderNo}).then(r r.json()); return wx.chooseWXPay(cfg); // 需要提前 wx.config 注入签名 } const { h5Url } await fetch(/api/pay/h5/create?outTradeNo${orderNo}, { method: POST }).then(r r.json()); window.location.href h5Url; // 外部浏览器直接跳转 }如果你们没做 JSAPI只做了 H5那微信内的降级方案只能是引导用户“点右上角在浏览器中打开”。这个引导页要做成可点击复制的链接形式因为部分机型不允许直接复制地址栏。体验很差但比让用户对着“请在外部浏览器打开”的白屏发呆强。4.3 结果页轮询把“支付中”当成一个正经状态用户从微信回到你的结果页时回调可能还没到。这时候不能直接显示“支付失败”也不能一直转圈得轮询async function pollStatus(orderNo, maxTimes 15) { for (let i 0; i maxTimes; i) { const res await fetch(/api/pay/status?outTradeNo${orderNo}).then(r r.json()); if (res.status SUCCESS) { location.replace(/pay/result?orderNo${orderNo}); // 用 replace 防止用户回退重复支付 return; } if (res.status CLOSED) { showFail(订单已关闭请重新下单); return; } await new Promise(r setTimeout(r, 1500)); // 1.5 秒一次别太密 } // 15 次约 22 秒还没结果落到“处理中”页面后台继续查 showPending(支付结果确认中稍后可在订单列表查看); }轮询间隔别设成 200 毫秒那只是给自家接口增加无意义的压力。1.5 到 2 秒一次、总共二十来秒是常见取值超过就跳到“处理中”页面让后端的查单补偿任务去收尾。用location.replace而不是location.href是为了避免用户点返回又回到支付页重复下单。5. 避坑与排查签名 401、回调丢失、金额单位这几处血泪经验下面几条都是我在实际项目里撞过的按“现象 → 原因 → 解决”写遇到同类问题可以直接对照。现象所有 v3 接口返回 401错误码SIGN_ERROR但签名代码看着没错。原因通常有三个一是签名串里的 URL 漏了 query string查单、关单、退款这类带路径参数或查询参数的接口必错二是请求体被序列化了两次签名用的字符串和HttpClient实际发出去的字节不一致常见于用Map传参又让框架自己转 JSON 的写法三是私钥格式不对商户平台下载的是 PKCS#8如果你把它转成 PKCS#1 再读Signature初始化阶段就会报错但有些人捕获异常后吞掉了最后表现为 401。 解决在签名方法里把message原文打成 debug 日志和官方文档的示例逐字符比对重点看换行符和末尾的空行私钥统一用 PKCS#8 加载读取时去掉 PEM 的头尾行和换行。现象下单成功用户也付了钱但回调接口一条日志都没有。原因notify_url用了 http、带了 query 参数、指向内网地址或者被公司网关的 WAF 拦了 POST。还有一个隐蔽的回调接口要求登录鉴权微信的请求没带 token直接被 302 到登录页微信收到 302 判定失败。 解决notify_url用纯 https 且不带任何参数回调路径加进网关鉴权白名单先用 curl 从公网机器上打一次自己的回调地址确认能拿到 200。现象同一笔订单发了两遍货用户收到两份。原因微信重试机制叠加没做幂等业务代码是“查状态 → 如果不是成功就发货 → 更新状态”并发下两次请求都读到未成功。 解决改成带条件的UPDATE ... WHERE statusPAYING用影响行数判断是否首次处理再加transaction_id唯一索引兜底。发货这类不可逆动作最好挂在本地消息表上异步执行别塞在回调线程里。现象金额全部差 100 倍1 元的商品显示 1 分。原因微信支付全链路金额单位是分前端传的是元后端没乘 100 就往下传或者反过来数据库存的是分展示时又除了一次。 解决约定死一条规则——进入支付层之前一律转成分用long存字段名带上_fen后缀。退款金额同样以分为单位且不能超过原订单金额。现象用户在微信里点支付页面提示“请在外部浏览器打开”转化率断崖。原因场景选错了。H5 支付MWEB只支持外部浏览器微信内置浏览器必须走 JSAPI。 解决在入口处用 UA 判断微信内走 JSAPI外部浏览器走 H5 JSAPI 需要openid可以通过公众号网页授权拿到。如果短期内做不了 JSAPI至少做一个可复制的引导页别让用户卡在白屏上。现象h5_url生成后过一会儿再点提示链接失效。原因h5_url有效期是 5 分钟很多人习惯下单后把链接存库、通过短信或消息推送发给用户。 解决链接不落库改成推送一条“去支付”的通知用户点进来时实时下单。如果一定要预生成就把有效期一起存下来超过 4 分钟直接重新生成。6. 上线前的验证清单与一套可复用的自测脚本功能跑通不等于能上线。我一般会在提测前把下面这张表走一遍尤其是前面几项出问题概率最高检查项判定标准出问题的后果商户私钥加载用 PKCS#8 能正常初始化Signature所有 v3 接口 401平台证书/公钥缓存启动时拉取成功过期前自动刷新验签失败回调全部丢弃APIv3 密钥长度正好 32 字节回调解密抛AEADBadTagExceptionnotify_url可达性公网 curl 返回 200回调收不到订单卡在 PAYING回调幂等同一报文连发 5 次只发货 1 次重复发货、重复加积分金额单位全链路分数据库字段带_fen差 100 倍h5_url时效下单后 4 分钟内使用用户点开提示失效微信内 UA自动降级到 JSAPI 或引导页转化率骤降关单逻辑超时订单调/close并置为 CLOSED库存被长期占用自测脚本不用写得多复杂一个能复现签名串的单元测试就够挡住八成问题。我通常会把官方文档给出的示例参数固化成测试用例断言自己生成的签名串和预期字符串完全相等——注意是字符串相等不是“验签通过”因为验签通过只能说明你自己的签名和验签是自洽的签错位置照样自洽Test void signMessageShouldMatchOfficialSample() { String message POST\n /v3/pay/transactions/h5\n 1554208460\n 593BEC0C930BF1AFEB40B4A08C8FB242\n {\appid\:\wxd678efh567hg6787\}\n; // 用固定的测试私钥签比对 Base64 结果换密钥时同步更新期望值 String actual signer.sign(message); assertEquals(EXPECTED_SIGNATURE, actual); }写完这个测试把message打成一行带可见换行符的日志比如把\n替换成\\n再打印对着文档逐段核。这一步花十分钟能省掉后面两小时的 401 排查。还有一个习惯所有和微信交互的请求响应都落一份脱敏日志保留Wechatpay-Request-Id这个响应头。真出问题时拿着这个 ID 去找微信侧核对比自己瞎猜快得多。日志里手机号、openid 这类字段要打码但订单号、金额、状态码必须完整保留。从那以后我每次接新的支付渠道都会先把“签名串自测 回调连发五次 公网 curl 回调地址”这三件事跑一遍再提测翻车的次数确实少了很多。这套 H5 支付的 Java 源码按上面的顺序读下来下单、回调、前端三段都能直接对上号剩下的就是把自己的商户参数填进去。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?