简介:面向Java后端开发者与电商项目实践者的H5微信支付接入示例包,聚焦在网页端完成微信支付闭环这一常见需求,适合已具备Java基础、希望快速理解支付接口调用流程的中级开发者参考。压缩包共11个文件、约16KB,以5个java源码和3个xml配置为主体,辅以properties配置文件、html支付页面与doc部署说明,覆盖统一下单、支付链接生成、回调处理、订单查询等关键环节的代码骨架。已有1320人学习下载,说明其在同类接入场景中具备一定参考价值。读者可从中获得可运行的工程结构与配置样例,结合部署文档快速搭建测试环境,并借助测试相关文件模拟支付场景,理清签名生成、异步通知校验与异常重试的设计思路,减少对接微信支付接口时的试错成本。
1. 从一次支付回调丢单说起:Java 后端怎么把 H5 微信支付跑通
去年帮一个做知识付费的朋友排查问题,用户付了钱,订单状态却一直停在「待支付」,客服每天被追着问。翻日志发现,支付平台的异步通知其实早就打过来了,只是后端接口返回了非成功应答,平台按重试策略连推几次后放弃,订单就此成了黑匣子。这类问题在 H5 微信支付接入里太常见了——真正难的不是调起支付那一下,而是签名、回调、对账这一整条链路。这份 JavaH5 微信支付资源包,给的就是一套能直接嵌进 Spring Boot 项目的下单、签名、回调、查单骨架,适合正在做移动端网页收款的后端同学,也适合想搞清楚「为什么我的 notify 收不到」的熟手拿来对照。下面按我拆包的顺序,把能复现的部分和翻车点一次讲透。
2. 下单与签名:把统一下单接口的参数一次配对
2.1 先搞清楚 H5 支付和 JSAPI 的边界
很多人一上来就把 H5 支付和公众号 JSAPI 支付混着写,结果调起时页面白屏或者报「当前环境不支持」。两者的核心差别在于:JSAPI 依赖微信内置浏览器里的用户身份(需要 openid 换 prepay_id,再走WeixinJSBridge调起),而 H5 支付面向的是手机外部浏览器,返回的是一个mweb_url,由前端跳转过去唤起微信客户端完成付款。资源包里把这两条链路拆成了独立的 service,H5PayService只负责trade_type=MWEB的下单,不掺 openid 逻辑,这个分层是对的。
选型上,如果你收款场景是「用户在手机浏览器打开落地页直接买」,就走 H5;如果是「公众号菜单里点进来买」,走 JSAPI。别想着用一套参数通吃,scene_info里的h5_info字段在 H5 场景是必填的,漏了会直接返回参数错误。资源包里对h5_info做了封装,wap_url、wap_name两个字段按域名和站点名填,虽然文档说部分场景可省,但实测不少商户号校验得严,建议老老实实填全。
2.2 统一下单的请求体怎么拼
下单接口的坑集中在参数命名和类型上。微信 v2 接口用的是下划线命名,金额单位是「分」,而且签名只对非空字段参与。资源包里有一段buildUnifiedOrderParams的方法,我按它的思路整理成下面这段,可以直接对照自己的实现:
public Map<String, String> buildUnifiedOrderParams(String outTradeNo, int totalFee, String body, String notifyUrl, String ip) { Map<String, String> params = new TreeMap<>(); // TreeMap 保证 key 字典序,签名时省一次排序 params.put("appid", appId); params.put("mch_id", mchId); params.put("nonce_str", UUID.randomUUID().toString().replace("-", "")); // 32 位随机串,别用时间戳凑 params.put("body", body); // 商品描述,中文要确保 UTF-8 params.put("out_trade_no", outTradeNo); // 商户订单号,全局唯一,重试要复用同一个 params.put("total_fee", String.valueOf(totalFee)); // 单位分,1 元 = 100 params.put("spbill_create_ip", ip); // 终端 IP,H5 场景传用户真实出口 IP params.put("notify_url", notifyUrl); // 必须是公网可访问的 https 地址 params.put("trade_type", "MWEB"); params.put("scene_info", buildSceneInfo()); // h5_info 的 JSON 字符串 params.put("sign", sign(params, apiKey)); // 最后算签名,sign 本身不参与签名 return params; }逻辑上几个点必须说清。第一,TreeMap的字典序和微信要求的 ASCII 升序一致,省得自己再排一遍,但如果你用HashMap再手动排序,注意String.compareTo对大小写敏感,别用错比较器。第二,nonce_str用 UUID 去横线是常见做法,长度 32 位,别图省事用System.currentTimeMillis(),重复概率不低。第三,total_fee是 int 转字符串,千万别传BigDecimal的元,否则 9.9 元会变成 9.9 分甚至报类型错。第四,notify_url不能带参数、不能是内网地址,本地联调时用内网穿透工具映射一个临时域名,这个后面避坑章还会提。
2.3 签名算法:MD5 和 HMAC-SHA256 别混用
资源包默认走 MD5,这也是 v2 接口最常用的。签名步骤是:剔除sign字段和空值,按 key 字典序拼成k1=v1&k2=v2形式,末尾拼上&key=API密钥,再做 MD5 转大写。下面这段是核心:
public static String sign(Map<String, String> params, String apiKey) { StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> e : params.entrySet()) { if ("sign".equals(e.getKey()) || e.getValue() == null || e.getValue().isEmpty()) { continue; // 空值和 sign 本身不参与 } sb.append(e.getKey()).append("=").append(e.getValue()).append("&"); } sb.append("key=").append(apiKey); // 注意是 &key=xxx,不是直接拼 key return DigestUtils.md5Hex(sb.toString()).toUpperCase(); // 32 位大写 }参数说明:apiKey是商户平台里设置的 API 密钥,32 位,别和 APIv3 密钥搞混,v2 用前者。DigestUtils来自 commons-codec,资源包里已经带了依赖。这里最容易翻车的是「空值处理」——有些字段传了空字符串"",有人只判null不判空串,导致签名串多了一个k=&,服务端算出来对不上,返回「签名错误」。另外,如果你在商户平台开启了 HMAC-SHA256,签名方式要换成对应的算法,MD5 的结果会被拒,这个开关在「账户中心-API 安全」里,接手别人项目时先确认。
2.4 拿到 mweb_url 之后前端怎么接
下单成功后返回的 XML 里,mweb_url是给前端跳转用的。资源包里的 controller 把 XML 解析成对象后只回传mweb_url和prepay_id,前端window.location.href = mweb_url即可唤起微信。这里有个细节:mweb_url后面可以拼&redirect_url=回调地址,用户付完款会跳回你的页面,但这个跳转不代表支付成功,只是用户体验层面的回跳,真正的到账以异步通知为准。我见过有项目拿 redirect 回跳当支付成功去发货,结果用户中途取消也发了货,血泪经验。资源包里在回跳页做了「查询订单状态」的兜底,这个设计值得抄。
3. 回调通知:验签、幂等和应答一个都不能少
3.1 notify 接口的验签顺序
异步通知是整条链路里最不能出错的一环。微信会把下单时的参数加上result_code、transaction_id等字段,用同样的规则签名后 POST 到你的notify_url,格式是 XML。资源包的PayNotifyController处理顺序是:读原始流 → 解析 XML → 验签 → 判断return_code和result_code→ 业务处理 → 返回应答。验签必须用原始参数重新算,不能拿解析后的对象随便拼,因为 XML 里字段顺序和空值都可能影响结果。下面是我按资源包逻辑整理的验签片段:
@PostMapping("/pay/notify") public String notify(@RequestBody String xmlBody) { Map<String, String> notifyMap = XmlUtil.xmlToMap(xmlBody); // 保留原始键值 String signFromWx = notifyMap.get("sign"); notifyMap.remove("sign"); String calcSign = SignUtil.sign(notifyMap, apiKey); // 复用下单时的签名方法 if (!calcSign.equals(signFromWx)) { return "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[sign error]]></return_msg></xml>"; } // 验签通过后再看业务结果 if (!"SUCCESS".equals(notifyMap.get("return_code")) || !"SUCCESS".equals(notifyMap.get("result_code"))) { return successXml(); // 非成功也回 SUCCESS,避免无意义重推 } // 幂等处理见下一节 return successXml(); }参数说明:xmlBody用@RequestBody拿原始字符串,别用@RequestParam逐字段接,否则特殊字符会被转义破坏签名。返回的 XML 里return_code必须是SUCCESS,微信才认为通知成功、停止重推;返回FAIL或超时(默认 5 秒)都会触发重试,重试策略是 15s、15s、30s、3m、10m…逐步拉长,总共 8 次左右。所以接口要快,业务逻辑重的部分(发短信、写多表)建议丢到异步线程或消息队列,主流程只做验签和状态更新。
3.2 幂等:同一笔通知会来很多次
微信的通知是「至少一次」语义,同一笔订单收到多次通知是常态,尤其在你返回 FAIL 或者响应慢的时候。资源包里用out_trade_no加数据库唯一索引做幂等,更新订单状态时带上where status = '待支付'条件,受影响行数为 0 就直接返回成功。这个思路比「先查再更新」靠谱,因为查和更新之间有并发窗口。下面这段是典型的幂等更新:
// 只有从「待支付」流转到「已支付」才真正处理业务 int affected = orderMapper.updateStatusIfPending(outTradeNo, "已支付", transactionId); if (affected == 0) { // 已经处理过,直接应答成功,避免重复发货 return successXml(); } // 首次处理:发货、加积分、发通知,全部放异步 businessExecutor.execute(() -> deliverGoods(outTradeNo)); return successXml();这里的关键是updateStatusIfPending的 SQL 要写成UPDATE orders SET status='已支付', transaction_id=#{tid} WHERE out_trade_no=#{no} AND status='待支付',靠数据库行锁保证原子性。别用 Redis 分布式锁去凑,除非你已经有现成的基础设施,否则多一个组件多一个故障点。另外,transaction_id是微信侧的交易号,退款和对账都要用,务必落库,别只存商户订单号。
3.3 应答格式和超时控制
应答必须是标准 XML,return_code和return_msg两个字段,成功时return_msg可以空。资源包里封装了successXml()和failXml(msg)两个方法,避免手拼字符串漏 CDATA。超时方面,微信要求 5 秒内响应,但实际网络抖动下,你的接口如果同步查了第三方物流或者发了邮件,很容易超。我的做法是 notify 接口只做「验签 + 幂等更新 + 投递消息」,其余全部异步,接口 P99 控制在 200ms 以内。还有一点,notify_url如果配了 https,证书链要完整,某些老版本 JDK 对证书校验严,会直接握手失败,日志里表现为连接重置,这个在避坑章细说。
4. 查单、对账与退款:把「钱去哪了」查清楚
4.1 主动查单补上回调丢失的缺口
回调再可靠也有丢的时候——用户网络断了、你的服务重启了、防火墙拦了。资源包里带了一个定时任务,对「待支付」超过 5 分钟的订单调orderquery接口主动查一次,查到已支付就补状态。查单接口的参数很简单:out_trade_no或transaction_id二选一,加上appid、mch_id、nonce_str、sign。返回里trade_state有SUCCESS、REFUND、NOTPAY、CLOSED等值,只有SUCCESS才代表钱到账。下面是对账任务的骨架:
@Scheduled(fixedDelay = 5 * 60 * 1000) // 每 5 分钟跑一次 public void reconcilePendingOrders() { List<Order> pending = orderMapper.selectPendingOlderThan(5); // 超过 5 分钟仍待支付 for (Order o : pending) { Map<String, String> resp = wxPayClient.orderQuery(o.getOutTradeNo()); if ("SUCCESS".equals(resp.get("trade_state"))) { // 复用回调里的幂等更新逻辑,保证两条路径行为一致 handlePaid(o.getOutTradeNo(), resp.get("transaction_id")); } else if ("CLOSED".equals(resp.get("trade_state"))) { orderMapper.closeIfPending(o.getOutTradeNo()); // 关单,释放库存 } } }参数说明:fixedDelay而不是fixedRate,避免上一次没跑完就叠加。selectPendingOlderThan(5)里的 5 是分钟,别设太小,否则刚下单还没付就被查,白白增加调用量。查单接口有频率限制,单商户号每秒别超过阈值,批量查的时候加个Thread.sleep或者用令牌桶限流。资源包里对查单结果做了缓存,同一订单 30 秒内不重复查,这个细节能省不少配额。
4.2 退款接口的证书加载
退款比下单多一道坎:需要双向证书。商户平台下载的apiclient_cert.p12要加载进SSLContext,密码是商户号。资源包里用KeyStore加载 p12,再构造HttpClient发请求,这段代码网上抄来抄去容易漏掉「证书路径不能放 classpath 里被 jar 打包」的问题——p12 文件放resources下打成 jar 后,FileInputStream读不到,得用getResourceAsStream转成字节再load。退款参数里out_refund_no是退款单号,同一笔订单多次部分退款要保证唯一;total_fee是原订单金额,refund_fee是本次退款金额,单位都是分。退款结果也是异步通知,refund_status为SUCCESS才算成功,别拿同步返回的result_code当最终结果。
4.3 对账文件的下载与解析
每天上午微信会生成前一天的账单,通过downloadbill接口拉取,bill_date格式yyyyMMdd,bill_type传ALL。返回的是 CSV,字段用逗号分隔,第一行是表头,最后两行是汇总。资源包里用OpenCSV解析,注意账单里的金额单位是「元」而不是「分」,和接口参数不一致,直接拿来比对会差 100 倍。对账逻辑建议做成「本地订单表 left join 账单」,找出「本地已支付但账单没有」和「账单有但本地没记录」两类差异,前者可能是回调伪造或重复发货,后者可能是漏单。这个任务跑通一次,后面每天的账就心里有数了。
5. 避坑清单:那些让联调卡半天的细节
5.1 签名错误但参数看着都对
现象:本地用工具算的签名和代码算的一致,调接口却返回「签名错误」。原因多半是字符编码或空值处理不一致——XML 里中文没按 UTF-8 编码,或者某个可选字段传了空串被算进签名串。解决:统一用UTF-8读请求体,签名前显式剔除null和"",并用微信官方提供的签名校验工具对一遍。还有一种情况是 API 密钥复制时带了空格,肉眼看不出来,重新生成一次最稳。
5.2 notify 一直收不到
现象:支付成功,但后端日志里没有任何回调记录。原因可能是notify_url是内网地址、端口没对外开放、https 证书不被信任,或者被安全组拦了。解决:先用curl从公网机器 POST 一个模拟 XML 到你的接口,确认链路通;再看 Nginx 日志有没有 502/504;证书问题用openssl s_client检查证书链是否完整。本地开发用内网穿透映射临时域名,别直接填localhost。
5.3 回调验签通过但金额对不上
现象:验签成功,但total_fee和本地订单金额不一致。原因通常是下单时金额单位搞错(元当成分),或者订单号复用了导致串单。解决:下单前对total_fee做一次断言,必须是正整数且等于BigDecimal元乘 100 后的值;out_trade_no用「业务前缀 + 日期 + 序列」保证唯一,别用自增 ID 直接拼,分库分表后会冲突。
5.4 退款报「证书不存在」
现象:退款接口抛FileNotFoundException或KeyStoreException。原因就是前面说的 p12 被打进 jar 后路径失效,或者密码传错(密码是商户号,不是 API 密钥)。解决:用getResourceAsStream读证书流转KeyStore.load,密码从配置中心取,别硬编码。另外 p12 文件别提交到公开仓库,放配置目录或对象存储,启动时拉取。
5.5 定时查单把数据库打满
现象:对账任务一跑,数据库 CPU 飙高。原因是一次性select出全量待支付订单再逐条查,订单量大时内存和连接都扛不住。解决:分页查,每批 200 条,查完一批再查下一批;查单结果批量更新,别一条一条update。资源包里默认分页大小 100,可以按实际调,但别超过 500。
6. 进阶:把支付链路做成可观测的
资源包跑通之后,我做的第一件事不是加功能,而是给整条链路埋点。下单、回调、查单、退款四个环节各打一条结构化日志,字段包括out_trade_no、stage、cost_ms、result,落到 ELK 里按订单号一搜,整条时间线清清楚楚。下面这个切面是我后来加的,资源包里没有,但强烈建议补上:
@Around("@annotation(WxPayLog)") public Object logPayStage(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); String stage = ((MethodSignature) pjp.getSignature()).getMethod().getName(); try { Object ret = pjp.proceed(); log.info("wxpay stage={} cost={}ms result=ok", stage, System.currentTimeMillis() - start); return ret; } catch (Exception e) { log.error("wxpay stage={} cost={}ms result=fail", stage, System.currentTimeMillis() - start, e); throw e; } }配合一个简单的看板:回调成功率、平均响应耗时、查单补单数量、退款失败数。回调成功率低于 99% 就该查网络或证书了;补单数量突然上涨,说明回调链路有抖动。这些指标比事后翻日志高效得多。
还有一个验证习惯值得说:每次改完签名或回调逻辑,我会用沙箱环境跑一遍「下单 → 支付 → 回调 → 查单 → 退款」全流程,再故意把notify_url改错一次,确认查单任务能把状态补回来。这个「破坏性验证」帮我提前发现过两次配置遗漏。从那以后我每次接新的支付渠道,都强制走一遍「正常流程 + 断掉回调 + 主动补单」三件套,不跑完不合并代码。希望这套拆解能帮到你,少在签名和回调上耗时间。
本文还有配套的精品资源,点击获取