ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java微信支付H5支付全链路:下单回调查单退款与幂等避坑

Java微信支付H5支付全链路:下单回调查单退款与幂等避坑

简介:这份资源围绕Java后端接入H5微信支付这一主题整理,面向需要为电商网站、移动端网页快速补齐支付能力的Java开发者,以及正在学习微信支付接口调用流程的初中级工程师,重点解决统一下单、支付结果通知、订单查询等环节的落地问题。压缩包共11个文件、约16KB,以5个Java源码和3个XML配置为主体,还原了后端调用微信支付接口的核心逻辑;另有properties配置文件、一份部署说明文档和H5页面文件,分别对应参数配置、环境搭建指引与前端唤起支付的展示环节,便于按模块对照理解。资源已有1322人学习下载,具备一定的参考基础。内容覆盖预支付会话标识获取、支付链接生成与签名、异步回调校验、订单状态确认,以及异常重试、HTTPS传输与测试环境调试等要点,读者可据此梳理出一条较完整的H5支付链路,并结合自身项目排查签名错误、回调丢失等常见问题。

1. 一笔订单卡在「已扣款未发货」:Java 侧在 H5 支付里到底管哪几段

「用户说钱扣了,后台还是待付款」——这类工单在 Java 微信支付项目里几乎每个月都会出现一次。原因往往不是接口调不通,而是 H5支付 这条链路只接了下单一段:前端从 mweb_url 跳出去,付完再跳回 redirect_url,商户页刷新一下没查到结果,就把用户当成了未支付。真正决定订单状态的是服务端那条异步回调,以及回调丢失之后的主动查单。

这套 Java 侧的 H5 支付实现,把「下单、回调、查单、关单、退款、对账」六段补齐,适合已经能跑通下单接口、但一到线上就被签名失败、金额差一百倍、回调重复发货绊住的后端同学。它不解决业务建模,解决的是钱和订单对不上的问题。

2. 统一下单:MWEB 交易类型下必须由服务端算的八个字段

H5 支付在统一下单接口里对应trade_type=MWEB,和 JSAPI 最大的区别是它不依赖公众号 openid,也不返回prepay_id给前端去调WeixinJSBridge,而是返回一个mweb_url,让浏览器直接跳过去拉起客户端收银台。这个差异决定了参数结构:少了 openid,多了scene_info;前端拿不到任何可用于再次下单的凭据,所以下单必须在服务端完成并落库。

2.1 请求体:哪些字段必须服务端算,哪些绝不能信前端

我一般把下单参数分成三类:服务端自己生成的、从订单表里读出来的、前端传上来只做校验的。金额和商品描述属于第二类,前端传过来的金额只能用来做一致性比对,一旦直接进total_fee,就是一个明晃晃的改价漏洞。

字段来源说明
appid / mch_id配置文件与商户平台绑定,多商户场景按租户路由
nonce_str服务端生成32 位以内随机串,每次请求都换
body订单表商品简述,会展示给用户,别塞内部编码
out_trade_no服务端生成32 字符内,同一商户号下唯一,建议带业务前缀
total_fee订单表单位是分,整型,不允许出现小数
spbill_create_ip请求上下文终端用户 IP,不是服务器内网 IP
notify_url配置中心公网可达的 HTTPS 地址,不带自定义参数
scene_info服务端拼装H5 场景必填,描述拉起收银台的来源页面

字段清单里最容易出错的是spbill_create_ip。这个字段语义上要求填写终端用户 IP,在 Nginx 反代之后request.getRemoteAddr()拿到的往往是网关地址,正确做法是从X-Forwarded-For的第一个非内网地址取值,取不到再降级。

public String buildUnifiedOrderXml(Order order, String clientIp) { Map<String, String> p = new HashMap<>(); p.put("appid", appId); p.put("mch_id", mchId); p.put("nonce_str", RandomUtil.nonce(32)); // 每次请求都要换,不能缓存复用 p.put("body", order.getSubject()); // 展示给用户的商品描述 p.put("out_trade_no", order.getOrderNo()); // 商户订单号,幂等键 p.put("total_fee", String.valueOf(order.getAmountFen())); // 单位:分 p.put("spbill_create_ip", clientIp); // 终端用户 IP p.put("notify_url", notifyUrl); // 公网 HTTPS 回调地址 p.put("trade_type", "MWEB"); p.put("time_expire", order.getExpireAt().format(TIME_FMT)); // yyyyMMddHHmmss // H5 场景必填:type 固定 Wap,wap_url 必须是已登记的支付域名下的页面 p.put("scene_info", "{\"h5_info\":{\"type\":\"Wap\"," + "\"wap_url\":\"https://m.example.com\"," + "\"wap_name\":\"订单支付\"}}"); p.put("sign", PaySign.sign(p, apiV2Key, "MD5")); // 签名最后算 return XmlUtil.toXml(p); }

这段代码有两个顺序约束:sign必须在所有业务字段写入之后才算,time_expire要和本地订单的过期时间对齐,否则会出现平台侧还能付、本地已经把订单关掉的情况。body不要用用户输入直接拼接,长度和特殊字符都会在下单时校验失败。

2.2 签名:MD5 与 HMAC-SHA256 两种实现的差异点

签名失败的报错信息通常只有一句「签名错误」,剩下的只能靠自己对。算法的骨架是固定的:剔除sign字段和空值,按参数名 ASCII 升序,拼成k=v&k=v的形式,末尾追加&key=API密钥,再做摘要并转大写。

差异点在两处。第一,sign_type选MD5时用 MD5 摘要,选HMAC-SHA256时用 API 密钥作为 HMAC 的 key,摘要输出同样是十六进制大写。第二,参与签名的值必须是「最终发送的值」——如果某个字段在 XML 里做了 CDATA 包裹但值本身带空格,签名算的是原值,不是包裹后的字符串。

public static String sign(Map<String, String> params, String apiKey, String signType) { // TreeMap 天然按 key 的 ASCII 升序,省掉手工排序 Map<String, String> sorted = new TreeMap<>(params); sorted.remove("sign"); // sign 本身不参与签名 StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> e : sorted.entrySet()) { String v = e.getValue(); if (v == null || v.trim().isEmpty()) continue; // 空值不参与 sb.append(e.getKey()).append('=').append(v).append('&'); } sb.append("key=").append(apiKey); // 末尾拼密钥,不要加 & String raw = sb.toString(); if ("HMAC-SHA256".equalsIgnoreCase(signType)) { return hmacSha256Hex(raw, apiKey).toUpperCase(); // key 同样是 API 密钥 } return md5Hex(raw).toUpperCase(); // 统一大写 }

这段实现里StringBuilder最后的拼接细节是最容易翻车的地方:不是&key=,是&已由上一轮循环补上,后面直接接key=。很多「签名错误」最后定位到的就是这里多了一个或两个&。另外,hmacSha256Hex的第二个参数是密钥,不是报文,参数顺序写反了同样只会得到「签名错误」,没有任何提示。

2.3 mweb_url 的 redirect_url 拼接与回跳落地页处理

拿到的响应是 XML,先判return_code(通信层)再判result_code(业务层),两个都是SUCCESS才有mweb_url。这个 URL 本身不带回跳地址,需要商户自己拼redirect_url参数,并且必须做 URL 编码,否则地址里的&会被截断,用户付完款会被带到半截地址上去。

String mwebUrl = resp.get("mweb_url"); // redirect_url 必须整体 urlencode,且只能带一个参数名,不能出现 # String back = URLEncoder.encode("https://m.example.com/pay/result?orderNo=" + orderNo, "UTF-8"); String jumpUrl = mwebUrl + "&redirect_url=" + back; redis.setex("pay:jump:" + orderNo, 300, jumpUrl); // mweb_url 有效期 5 分钟,别长期缓存

回跳页只承担展示职责:拿到orderNo后查一次本地订单状态,如果是待付款就展示「支付结果确认中」,同时提示前端每隔两秒轮询一次。绝对不要在回跳页里直接改订单状态——回跳是浏览器行为,用户完全可以在支付前就手动访问这个地址。mweb_url 的有效期文档写的是 5 分钟,超过之后再打开只会看到过期提示,所以下单成功就要立刻跳转,不能让用户在确认页停留太久。

3. 支付回调:验签、幂等、应答三段式,决定订单会不会重复发货

回调是整条链路里唯一可信的到账依据。它有两个特点:一是可能重复投递,二是可能根本不到。前者靠幂等解决,后者靠查单解决。把这两件事分开处理,回调本身的逻辑其实很短:验签、判状态、落库、应答。

3.1 通知报文解析:先验签还是先解析

顺序必须是「读原始报文 → 解析成 Map → 剔除 sign 重新计算 → 比对 → 再处理业务」。不能先看result_code是 SUCCESS 就直接发货,那个字段在网络上是明文的,任何人都能伪造一个 POST 请求发到你的回调地址上。

解析用 DOM 时记得关掉外部实体,否则回调接口就是一个 XXE 入口,这种事在支付链路上出现一次就够写复盘了。

@PostMapping(value = "/pay/notify", produces = "application/xml;charset=UTF-8") public String notify(@RequestBody String rawBody) { Map<String, String> notify; try { // 关闭 DTD 与外部实体,避免 XXE DocumentBuilderFactory f = DocumentBuilderFactory.newInstance(); f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); f.setFeature("http://xml.org/sax/features/external-general-entities", false); f.setFeature("http://xml.org/sax/features/external-parameter-entities", false); notify = XmlUtil.toMap(f.newDocumentBuilder() .parse(new ByteArrayInputStream(rawBody.getBytes(StandardCharsets.UTF_8)))); } catch (Exception e) { return fail("报文解析失败"); // 解析失败也要回 XML,不能抛 500 } String sign = notify.get("sign"); // 用同一套规则重新计算,注意这里用的是通知报文里的原始值 if (!PaySign.verify(notify, sign, apiV2Key, "MD5")) { log.warn("回调验签失败 orderNo={}", notify.get("out_trade_no")); return fail("签名失败"); } if (!"SUCCESS".equals(notify.get("return_code")) || !"SUCCESS".equals(notify.get("result_code"))) { return success(); // 业务失败的通知也要应答成功,避免无意义重试 } payService.handlePaid(notify); // 内部做幂等,见 3.2 return success(); }

verify的实现要先把收到的sign从 Map 里摘出去再算,否则自己会把自己算进去。另外要注意字符编码:报文按 UTF-8 读,但早期有些实现按平台默认编码读,中文商品名一出现签名就对不上,这类问题在本地很难复现,因为本地默认编码恰好也是 UTF-8。

3.2 幂等:唯一索引 + 状态机,别用 if 判断当前状态

回调重复是常态,不是异常。同一条通知在几秒内到两三次很常见,如果处理逻辑是「查到订单是待付款就发货」,在并发下两个线程同时读到待付款,就会发两次货。

我一般用两层防护:第一层是在支付流水表上建唯一索引,把transaction_id和out_trade_no都作为唯一键的一部分,重复插入直接抛DuplicateKeyException,捕获后直接返回成功;第二层是带状态条件的 UPDATE,把状态判断交给数据库的行锁。

-- 第一层:支付流水表,transaction_id 唯一 INSERT INTO pay_flow(out_trade_no, transaction_id, total_fee, notify_time) VALUES (#{outTradeNo}, #{transactionId}, #{totalFee}, #{notifyTime}); -- 第二层:带状态条件的更新,影响行数为 0 说明已被处理过 UPDATE orders SET status = 'PAID', transaction_id = #{transactionId}, pay_time = #{notifyTime} WHERE order_no = #{outTradeNo} AND status = 'WAIT_PAY';

两层的顺序是先插流水再更新订单,都在同一个本地事务里。流水插入失败说明这条通知处理过,直接返回;订单更新影响 0 行说明订单不在待付款状态,可能是已关闭或者已退款,这时候要记一条告警而不是静默丢弃——已关闭订单收到支付成功回调,是必须人工介入的场景。

提示:状态机的合法迁移要显式写出来(待付款→已支付→退款中→已退款),不要用status != 'PAID'这种反向条件,它会把已退款订单重新拉回已支付。

3.3 应答报文与重试策略:什么情况必须返回失败

回调接口的返回值决定了平台要不要重投,所以应答内容必须精确。处理成功返回SUCCESS,报文是固定的 XML;验签失败、参数缺失、系统异常返回FAIL并带上简短原因,平台会按递增间隔重投若干次,累计大约一天。

这里有个常见误判:业务上「订单已关闭」这种情况,返回 SUCCESS 还是 FAIL?我的做法是返回 SUCCESS,同时在本地记一条待人工核对的差异单。理由是这类通知重投多少次都不会变成功,继续重试只会放大日志量,而差异本身需要人来看。

private String success() { return "<xml><return_code><![CDATA[SUCCESS]]></return_code>" + "<return_msg><![CDATA[OK]]></return_msg></xml>"; } private String fail(String msg) { return "<xml><return_code><![CDATA[FAIL]]></return_code>" + "<return_msg><![CDATA[" + msg + "]]></return_msg></xml>"; }

还有一点容易被忽略:回调接口的Content-Type要能接受text/xml,有些网关默认只放行application/json,请求在网关层就被 415 拦掉了,平台侧看到的是超时,于是不停重投,而你的应用日志里一条记录都没有,排查时非常容易被误导。

4. 查单与退款:回调丢了之后的三条补偿通道

回调到达率统计下来不是 100%,网络抖动、发版重启、网关限流都会让某几条通知彻底丢失。所以「只靠回调改状态」的支付系统一定会有对不上的账,区别只是什么时候被发现。

4.1 主动查单:定时补偿与用户回跳触发的双通道

我一般做两条通道。第一条是用户触发:回跳页轮询时,如果本地仍是待付款,服务端顺手调一次查单接口,这是最实时的兜底。第二条是定时任务:扫描创建时间在 5 分钟到 2 小时之间、状态仍是待付款的订单,逐条查单,查到的结果按回调同样的逻辑处理。

查单接口的参数只有out_trade_no或transaction_id二选一,响应里的trade_state才是权威状态:SUCCESS已支付、NOTPAY未支付、CLOSED已关闭、REFUND已转入退款。注意REFUND只代表发生过退款,不代表全额退完,具体金额还要看退款查询。

// 定时任务里的一批查单,注意控制并发和调用频率 for (Order o : pendingOrders) { Map<String, String> p = new HashMap<>(); p.put("appid", appId); p.put("mch_id", mchId); p.put("out_trade_no", o.getOrderNo()); p.put("nonce_str", RandomUtil.nonce(32)); p.put("sign", PaySign.sign(p, apiV2Key, "MD5")); Map<String, String> r = payClient.postXml(ORDER_QUERY_URL, XmlUtil.toXml(p)); if ("SUCCESS".equals(r.get("trade_state"))) { // 复用回调的处理逻辑,幂等由 3.2 的流水表保证 payService.handlePaid(r); } else if ("CLOSED".equals(r.get("trade_state"))) { orderService.markClosed(o.getOrderNo()); } }

批处理要限速,单批不要超过几十条,间隔留出几百毫秒。查单接口本身也是有限流的,发版重启后一次性把几千条待付款订单全查一遍,很容易触发限流,结果是一条都没查到,还得再等一轮。

4.2 关单与超时:本地超时时间和平台订单有效期怎么对齐

本地订单过期时间和下单时传的time_expire必须是同一个值,而且本地要留一点余量。如果本地 30 分钟关单、平台也 30 分钟过期,用户在第 29 分 50 秒点支付,本地可能已经把订单关掉了,平台侧却还能付成功,于是就出现了「已关闭订单收到支付成功回调」的差异单。

我的做法是本地超时时间比平台短 1 分钟:平台侧 30 分钟过期,本地 29 分钟开始关单,并且关单前先调一次关单接口确认平台侧状态,只有在NOTPAY状态下才允许本地关闭。关单接口幂等,重复调用返回的错误码可以直接忽略。

// 关单:orderclose,用于本地主动关单前确认平台侧状态 p.put("out_trade_no", orderNo); // 返回 result_code=SUCCESS 表示关单成功;订单已支付或已关闭会返回对应 err_code

另外,用户下单后不付款是常态,所以待付款订单会一直堆在库里。定时关单任务要按创建时间倒序的小批量扫描,加索引(status, create_time),不要全表扫。

4.3 退款:金额校验、异步结果与对账文件

退款接口需要双向证书,这是它和下单接口最大的操作差异。p12 证书的密码默认是商户号,从 classpath 或配置的绝对路径读取都行,但绝不能写死在代码里。

KeyStore ks = KeyStore.getInstance("PKCS12"); try (InputStream in = new FileInputStream(certPath)) { ks.load(in, mchId.toCharArray()); // 证书密码就是商户号 } KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509"); kmf.init(ks, mchId.toCharArray()); SSLContext ctx = SSLContext.getInstance("TLS"); ctx.init(kmf.getKeyManagers(), null, null); // 用 ctx.getSocketFactory() 构造 HttpsURLConnection,其余流程同下单

退款金额有两道校验必须做:refund_fee不能大于total_fee,同一订单的累计退款额不能大于支付额。这两条在并发下靠数据库的唯一约束和行锁保证,不能只在 Java 里判断,否则两个退款请求同时进来就会超额退。退款结果是异步的,接口返回SUCCESS只代表受理成功,最终状态要等退款回调或者退款查询接口。对账文件是最后一道防线:下载当天的账单(bill_type=ALL),按out_trade_no逐行比对本地订单金额和状态,差异写进差异表。这个接口成功时返回的是 CSV 文本、失败时才返回 XML,且可能被 gzip 压缩,解析前先看响应头和首字节。

5. 避坑排查:签名报错、金额差一百倍与回调重复的五个现场

下面五条都是我在真实项目里遇到过的,按「现象 → 原因 → 解决」记录,症状描述尽量保留当时的原话,方便对照。

5.1 现象:所有下单都返回「签名错误」

原因:密钥用错了。商户平台上有 APIv2 密钥和 APIv3 密钥两套,前者是 32 位字符串,用于 v2 接口的 MD5/HMAC-SHA256 签名;后者是 32 位字符,用于 v3 的 RSA 签名。项目里两套接口混用,配置项名字又都叫apiKey,改一处就把另一处改坏了。

解决:配置项重命名为apiV2Key/apiV3Key,启动时校验长度;签名失败时把「排序后的待签名串」打到 DEBUG 日志里(密钥部分脱敏),和官方校验工具的结果逐字符比对,通常一眼就能看出多了一个&或者某字段的空值没被剔除。

5.2 现象:测试环境付 1 分钱,线上用户被扣了 100 倍

原因:金额单位是分,但前端传的是元,后端用double做了乘 100。19.9 * 100在浮点下是1989.9999999999998,转成整型被截断成 1989,看起来只差一点;而如果某处用了Math.round之后又乘了一次,就变成 100 倍。

解决:金额全链路用整型分存储,前端传元时用BigDecimal转换并setScale(0, RoundingMode.HALF_UP);下单前断言total_fee与订单表金额严格相等,不相等直接拒绝下单并告警,不做任何自动纠正。

5.3 现象:回调接口一直没收到请求,但用户确实付了钱

原因:notify_url走的是内网测试域名,或者经过网关时被Content-Type白名单拦掉了。还有一种更隐蔽:网关读了 POST body 做日志,读完没有重新包装 request,应用侧拿到的 body 是空字符串,解析直接抛异常返回 500。

解决:notify_url必须是公网可达的 HTTPS 地址,上线前用外网机器curl打一次确认能进到应用日志;网关侧对回调路径配置Content-Type: text/xml白名单并跳过 body 日志,或者在网关里做请求体缓存。

5.4 现象:同一个订单发了两份货

原因:回调处理里先select判断状态,再update,两个线程同时读到「待付款」。表面上是并发问题,根子上是没做幂等。

解决:按 3.2 的两层防护改造成唯一索引加条件更新,并在压测环境用同一个报文并发打 50 次验证:只有一次更新影响行数为 1,其余全部走重复分支。

5.5 现象:付完款跳回商户页变成 404,或者跳到了首页

原因:redirect_url没做 URL 编码,地址里自带的?orderNo=xxx中的&被截断;另一种是回跳地址用了未在商户平台登记的域名,跳转被拦。

解决:redirect_url整体URLEncoder.encode后再拼,拼接完打印一次完整 URL 人工点一遍;回跳域名提前在商户平台登记,路径尽量短,参数只留订单号一个,其余信息进服务端会话查。

6. 进阶:把支付调用收进一层网关,附上线前的四条自测断言

支付代码写到最后,业务层不该再出现任何 XML 和 sign 字样。我在项目里会抽一个PayGateway接口,只暴露prepay(order)、query(orderNo)、close(orderNo)、refund(refundOrder)四个方法,实现类里封装 HTTP、签名、XML 解析和证书加载。这样做的好处很直接:单测里塞一个FakePayGateway就能跑通全部订单状态流转,不需要每次都去连真实环境。

public interface PayGateway { PrepayResult prepay(Order order, String clientIp); // 返回 mweb_url 或错误码 TradeState query(String orderNo); void close(String orderNo); RefundResult refund(RefundOrder refundOrder); }

线上真正的自测,我固定跑这四条断言,少一条都不发版:

断言验证方式失败通常意味着
下单参数与订单表金额严格相等单测 + 下单前拦截金额单位或改价漏洞
同一回调报文并发 50 次只成功一次压测脚本重放幂等没做或做在应用层
回调接口 200ms 内返回固定 XML全链路压测回调里做了 RPC 或慢查询
关单后再次收到支付成功回调有告警手工构造通知状态机存在非法迁移

第三条最容易被忽略:回调处理里如果同步调了库存服务、消息队列、甚至发短信,一旦下游变慢,平台侧等的就是你的应答超时,然后开始重投,重投又加重你的负载,形成正反馈。正确做法是回调只做验签、幂等落库、投一条本地消息表,剩下的动作由消费者异步做。

血泪经验是那条「关单后收到支付成功回调」的告警。我一开始觉得这种场景不该出现,就没埋点,直到某次大促后对账,发现十几笔已关闭订单实际付了款,只能人工逐笔退款。从那以后我每次接入新的支付渠道,都会先把手动构造异常通知的用例跑一遍,再去看状态机有没有缺口。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表