简介:JavaH5微信支付是一套面向Java后端开发者与电商项目维护人员的H5支付接入示例,聚焦网页端调起微信支付、统一下单与异步回调等关键环节,适合需要快速理解支付链路或排查联调问题的初中级开发者参考。压缩包共11个文件,约16KB,以5个Java源码和3个XML配置为主体,辅以properties参数文件、doc部署说明与html页面,分别对应业务实现、工程配置、环境参数和前端调起示例,结构轻量便于按模块查阅。已有1322人学习下载,说明其在同类入门资料中具有一定参考热度。内容围绕统一下单生成预支付标识、支付链接与二维码展示、回调验签及订单状态更新展开,并涉及订单查询、异常重试、安全传输和测试/正式环境切换等要点,可帮助读者梳理从下单到回调确认的完整流程,也为部署配置与联调排错提供直接线索。
1. 从一次掉单说起:Java 微信支付 H5 支付到底卡在哪
Java 微信支付 H5 支付接入最容易被低估的不是统一下单,而是回调验签与订单状态机的一致性。很多项目下单能跳转,上线后却卡在掉单、重复通知和退款状态对不上。这份资源把 Java 后端的统一下单、签名、回调、查单、退款、对账拆成可复现的代码骨架,适合已经能写 Spring Boot 接口、准备把 H5 支付接进业务系统的开发者,也适合需要补幂等和补偿设计的熟手。
它不解决业务建模,但能把支付链路的黑匣子拆开,让你知道每一步该看哪个参数、失败时先查哪条日志。下面按“下单请求怎么拼、回调怎么验、状态怎么迁、退款对账怎么收口”的顺序推进,代码能直接对着改,参数和坑也会标出来。
2. 统一下单与签名链路:把 H5 支付请求拆成可复现的五步
H5 支付的下单接口不是“把金额和订单号发过去”这么简单。服务端要完成商户身份签名、构造场景信息、拿到可跳转的h5_url,还要让前端在超时后回到业务订单页。常见做法是后端只负责签名和落单,前端只负责跳转,不让前端碰私钥和签名串。这样选的原因很直接:私钥一旦进浏览器,等于把商户身份交出去;而 H5 支付的回调验签又依赖同一套证书体系,前后端职责混在一起,排查时根本分不清是签名错、参数错还是网络超时。
2.1 配置与证书加载:别把私钥写进代码
先解决配置。商户号、应用标识、证书路径、回调地址、下单网关地址都放配置中心或环境变量,代码里只保留占位符。证书加载要区分“商户私钥”和“平台证书”:商户私钥用来签请求,平台证书用来验回调。很多翻车现场是拿平台证书去签请求,日志里只报签名失败,不看证书序列号很难定位。我一般会把证书序列号、证书生效时间一起打进启动日志,但私钥内容绝不打。
@ConfigurationProperties(prefix = "pay.h5") public class H5PayProperties { private String appId; private String mchId; private String merchantSerialNo; private String privateKeyLocation; private String platformCertLocation; private String notifyUrl; private String unifiedOrderUrl; // getter / setter 省略,实际项目用构造注入或 Lombok }这段配置类的关键是merchantSerialNo和privateKeyLocation分开。merchantSerialNo是请求头里要带的商户证书序列号,privateKeyLocation指向 PKCS8 私钥文件,常见做法是放classpath:cert/apiclient_key.pem或挂载到容器外。notifyUrl必须是公网可达的 HTTPS 地址,本地开发用内网穿透时也要保证回调能到你的回调控制器,否则下单成功后只会看到“支付成功但订单未更新”。
@Component public class PrivateKeyLoader { private final H5PayProperties props; public PrivateKeyLoader(H5PayProperties props) { this.props = props; } public PrivateKey load() { try (InputStream in = new ClassPathResource(props.getPrivateKeyLocation()).getInputStream()) { String pem = new String(in.readAllBytes(), StandardCharsets.UTF_8) .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s+", ""); byte[] der = Base64.getDecoder().decode(pem); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); return KeyFactory.getInstance("RSA").generatePrivate(spec); } catch (Exception e) { throw new IllegalStateException("加载商户私钥失败,检查路径和 PKCS8 格式", e); } } }逻辑说明:先把 PEM 的头尾和空白去掉,再 Base64 解码成 DER,最后用PKCS8EncodedKeySpec生成PrivateKey。参数上最容易错的是私钥格式,常见 PEM 有 PKCS1 和 PKCS8 两种,Java 的KeyFactory直接吃 PKCS8;如果文件开头是BEGIN RSA PRIVATE KEY,需要先转换,不要硬试。另一个参数是证书序列号,必须和私钥匹配,轮换证书时两者要一起换。
提示:私钥文件不要提交到代码仓库,启动时校验文件权限,配置中心里存加密后的值,运行时解密。
2.2 构造下单参数:金额、场景信息与回调地址
H5 支付下单参数里,金额单位是“分”,不是元。很多 Java 项目在入参用BigDecimal表示元,落库时又用double转分,最后出现0.01变成0的精度事故。我一般会在进入支付服务前就把金额统一转成long分,后续所有计算都用分,展示时再除 100。场景信息scene_info也不是可选装饰,H5 支付通常要带payer_client_ip和h5_info.type,不同接入场景对h5_info的字段要求不一样,缺了会直接报参数错误。
| 参数 | 含义 | 常见设置 | 注意点 |
|---|---|---|---|
appid | 应用标识 | 从支付平台后台获取 | 不要和商户号混用 |
mchid | 商户号 | 从支付平台后台获取 | 测试与生产分开 |
out_trade_no | 商户订单号 | 业务唯一,建议带业务前缀 | 长度和字符集按接口要求 |
amount.total | 订单金额 | 整数,单位分 | 不要传小数 |
notify_url | 回调地址 | 公网 HTTPS | 必须能处理重复通知 |
scene_info | 场景信息 | H5 场景 | 字段缺失会报参数错误 |
public Map<String, Object> buildH5Order(String outTradeNo, long totalFen, String description, String clientIp) { Map<String, Object> body = new LinkedHashMap<>(); body.put("appid", props.getAppId()); body.put("mchid", props.getMchId()); body.put("out_trade_no", outTradeNo); body.put("description", description); body.put("notify_url", props.getNotifyUrl()); Map<String, Object> amount = new LinkedHashMap<>(); amount.put("total", totalFen); amount.put("currency", "CNY"); body.put("amount", amount); Map<String, Object> sceneInfo = new LinkedHashMap<>(); sceneInfo.put("payer_client_ip", clientIp); Map<String, Object> h5Info = new LinkedHashMap<>(); h5Info.put("type", "Wap"); sceneInfo.put("h5_info", h5Info); body.put("scene_info", sceneInfo); return body; }这段代码把金额、币种、场景信息按嵌套结构拼出来。totalFen必须是long,不要用Integer,更不要用Double。clientIp取真实用户 IP,不要取网关内网 IP,否则场景校验可能不过。description会出现在用户支付页面,长度和敏感字符要过滤,别把内部订单号之外的用户信息塞进去。LinkedHashMap是为了序列化时字段顺序稳定,排查日志时更直观。
2.3 签名与 HTTP 调用:签名串到底怎么拼
请求签名是 H5 支付链路里最容易“看着像对、跑起来就错”的地方。常见做法是:构造签名串时按固定顺序拼 HTTP 方法、URL 路径、时间戳、随机串、请求体,每一项用换行符连接,再用商户私钥做SHA256withRSA签名,最后放进Authorization头。签名串里不要带域名,也不要带查询参数,除非接口明确要求。时间戳和随机串要每次请求重新生成,不要缓存复用。
public String buildSignMessage(String method, String path, long timestamp, String nonce, String body) { return method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"; } public String sign(String message, PrivateKey privateKey) throws Exception { Signature signature = Signature.getInstance("SHA256withRSA"); signature.initSign(privateKey); signature.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(signature.sign()); }method用大写,path用下单接口的路径部分,body必须是实际发送的原始 JSON 字符串,不能是对象toString()的结果。签名前把 body 序列化一次并复用,签名和发送用同一个字符串,否则服务端验签必然失败。timestamp用秒级时间戳,和请求头里的时间戳保持一致;nonce用随机字符串,长度按接口要求。Authorization头一般拼成WECHATPAY2-SHA256-RSA2048 mchid="...",nonce_str="...",signature="...",timestamp="...",serial_no="..."这种形式,参数名和顺序按接口要求来,别自己发明。
public String postSigned(String url, String path, String body, PrivateKey key) throws Exception { long ts = Instant.now().getEpochSecond(); String nonce = UUID.randomUUID().toString().replace("-", ""); String message = buildSignMessage("POST", path, ts, nonce, body); String signature = sign(message, key); String auth = "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + props.getMchId() + "\"," + "nonce_str=\"" + nonce + "\"," + "signature=\"" + signature + "\"," + "timestamp=\"" + ts + "\"," + "serial_no=\"" + props.getMerchantSerialNo() + "\""; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("Content-Type", "application/json") .header("Accept", "application/json") .header("Authorization", auth) .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) .build(); HttpResponse<String> response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() >= 300) { throw new IllegalStateException("下单失败: " + response.statusCode() + " " + response.body()); } return response.body(); }这段代码把签名、请求头、HTTP 调用串起来。url是完整网关地址,path是签名用的路径,两者不要混。Content-Type必须是 JSON,body 用 UTF-8。返回状态码大于等于 300 时直接把响应体打出来,但日志里要脱敏,不要把完整签名和私钥打进去。常见失败是401签名错误、400参数错误,前者先查证书序列号和签名串,后者先查金额单位和scene_info。
2.4 返回 h5_url 后的前端跳转与超时处理
服务端拿到h5_url后,不要自己 302,而是把 URL 返回给前端,由前端在用户点击或页面加载时跳转。这样做的原因是 H5 支付需要在用户浏览器环境里完成跳转,服务端重定向会丢失部分场景信息。前端跳转前要把业务订单号记在本地,跳转后如果用户返回,前端用订单号轮询后端查单状态。轮询不要无限快,常见做法是 2 秒一次、最多 30 次,超过后提示“支付结果确认中”,由后端补偿任务兜底。
async function pay(orderNo) { const resp = await fetch(`/api/pay/h5/${orderNo}`, { method: "POST" }); const data = await resp.json(); if (!data.h5Url) { throw new Error(data.message || "下单失败"); } sessionStorage.setItem("pendingOrderNo", orderNo); window.location.href = data.h5Url; } async function poll(orderNo) { for (let i = 0; i < 30; i++) { const r = await fetch(`/api/order/${orderNo}/status`); const s = await r.json(); if (s.status === "PAID") return s; await new Promise((resolve) => setTimeout(resolve, 2000)); } return { status: "UNKNOWN" }; }前端只做跳转和轮询,不参与签名。sessionStorage只存订单号,不存金额和用户敏感信息。轮询接口要限流,避免用户疯狂刷新把查单接口打满。后端查单接口不要每次都实时调支付网关,先查本地订单状态,只有本地状态为INIT或PAYING且超过一定时间才触发主动查单。这样能把支付网关的查询压力降下来,也能避免重复通知和主动查单同时改状态。
3. 回调验签与状态机:订单幂等、重复通知和补偿怎么做
回调是 H5 支付里最不能“先跑通再说”的部分。支付平台会重复通知,网络超时也会触发重试,如果回调接口先改订单再验签,或者验签失败还返回成功,后面就会出现“钱扣了、订单没变、库存没扣”的烂账。常见做法是:回调入口只做验签、解密、落通知流水,业务处理放到独立事务里,处理成功返回成功应答,处理失败返回失败应答让平台重试。状态机要限制合法迁移,不能让PAID被重复通知改回INIT。
3.1 回调入口的第一道闸:验签与解密
回调验签要用平台证书,不是商户私钥。请求头里有签名、时间戳、随机串、证书序列号,服务端用对应平台证书公钥验签。验签前不要先把 body 反序列化成对象再序列化回去,必须用原始字节或原始字符串,否则空格和字段顺序变化会导致验签失败。常见做法是控制器入参用@RequestBody String body或byte[],先验签,再解析 JSON。
@PostMapping("/pay/notify") public ResponseEntity<String> notify(@RequestBody String body, @RequestHeader Map<String, String> headers) { try { String timestamp = headers.get("wechatpay-timestamp"); String nonce = headers.get("wechatpay-nonce"); String signature = headers.get("wechatpay-signature"); String serial = headers.get("wechatpay-serial"); String message = timestamp + "\n" + nonce + "\n" + body + "\n"; PublicKey platformKey = platformCertManager.getPublicKey(serial); if (!verify(message, signature, platformKey)) { return ResponseEntity.status(401).body("{\"code\":\"FAIL\",\"message\":\"验签失败\"}"); } // 验签通过后再解析,解析后的业务处理放独立事务 notifyService.handle(body); return ResponseEntity.ok("{\"code\":\"SUCCESS\",\"message\":\"成功\"}"); } catch (Exception e) { // 返回失败让平台重试,同时记录 traceId return ResponseEntity.status(500).body("{\"code\":\"FAIL\",\"message\":\"处理失败\"}"); } }timestamp、nonce、body三者拼签名串,顺序不能变。serial用来选平台证书,平台证书轮换期间可能同时存在新旧两张,所以证书管理器要支持按序列号取公钥。验签失败返回 401,业务处理失败返回 500,不要一律返回 200。很多掉单就是业务异常被吞掉、接口还返回成功,平台认为通知完成,不再重试。notifyService.handle里再做解密和业务处理,解密后的敏感字段不要直接打日志。
3.2 状态机设计:从 INIT 到 PAID 的合法迁移
订单状态不要用字符串散落各处,建议用枚举加迁移表。常见状态有INIT、PAYING、PAID、CLOSED、REFUNDING、REFUNDED。合法迁移只允许INIT -> PAYING、PAYING -> PAID、INIT -> CLOSED、PAID -> REFUNDING、REFUNDING -> REFUNDED。重复通知把PAID改成PAID要幂等返回,不能报错;把PAID改成INIT必须拒绝并告警,因为这通常意味着回调乱序或伪造。
| 当前状态 | 允许迁移 | 触发条件 | 失败处理 |
|---|---|---|---|
INIT | PAYING | 下单成功返回 h5_url | 记录网关响应 |
PAYING | PAID | 回调验签通过且金额一致 | 重复通知直接返回成功 |
PAYING | CLOSED | 超时未支付 | 主动查单确认 |
PAID | REFUNDING | 发起退款 | 退款单唯一 |
REFUNDING | REFUNDED | 退款回调成功 | 更新退款单 |
public enum PayStatus { INIT, PAYING, PAID, CLOSED, REFUNDING, REFUNDED; public boolean canMoveTo(PayStatus target) { return switch (this) { case INIT -> target == PAYING || target == CLOSED; case PAYING -> target == PAID || target == CLOSED; case PAID -> target == REFUNDING; case REFUNDING -> target == REFUNDED; default -> false; }; } }迁移判断放在服务层,更新 SQL 带上旧状态条件,避免并发回调同时改状态。canMoveTo只做规则判断,真正落库用update order set status = ? where order_no = ? and status = ?,受影响行数为 0 说明状态已被别的线程改过,直接重新查当前状态再判断。不要先select再update而不加条件,两个重复通知同时进来会互相覆盖。
3.3 幂等落库:唯一键、乐观锁与事务边界
回调幂等的第一层是通知流水表,用支付平台的通知 ID 或业务订单号加通知类型做唯一键。收到通知先insert流水,唯一键冲突说明重复通知,直接返回成功,不再执行业务。第二层是订单状态更新带旧状态条件。第三层是业务副作用,比如发货、加积分,要放在同一个本地事务里,或者用本地消息表保证最终一致。事务里不要调远程接口,远程调用超时会让事务持有锁,回调堆积后更麻烦。
CREATE TABLE pay_notify_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, notify_id VARCHAR(64) NOT NULL, out_trade_no VARCHAR(64) NOT NULL, notify_type VARCHAR(32) NOT NULL, body_hash VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_notify (notify_id, notify_type) ); CREATE TABLE pay_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, out_trade_no VARCHAR(64) NOT NULL, amount_fen BIGINT NOT NULL, status VARCHAR(16) NOT NULL, transaction_id VARCHAR(64), updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_out_trade_no (out_trade_no) );notify_id用平台通知里的唯一标识,没有就用out_trade_no + notify_type + body_hash兜底。body_hash用 SHA-256,既能去重又不在表里存明文回调。订单表金额用BIGINT存分,状态用VARCHAR存枚举名,方便排查。更新订单时用:
UPDATE pay_order SET status = 'PAID', transaction_id = ?, updated_at = NOW() WHERE out_trade_no = ? AND status = 'PAYING' AND amount_fen = ?;受影响行数为 1 才继续发货;为 0 时查当前状态,如果已经是PAID且transaction_id一致,视为重复通知直接返回成功;如果金额不一致,告警并人工介入。事务边界建议是“插通知流水 + 更新订单 + 写本地消息表”一个事务,发货由本地消息表异步消费,避免回调线程被库存服务拖死。
3.4 补偿任务:未收到回调时怎么查单
回调可能丢,网络可能断,所以必须有主动查单。常见做法是定时扫描PAYING且超过 2 分钟未更新的订单,调支付平台查单接口,查到SUCCESS就按回调同样的状态机处理,查到NOTPAY就等到超时关单。补偿任务要加分布式锁,避免多实例重复查单;查单频率要控制,不要一秒一次,常见是 30 秒到 1 分钟一轮,按订单量分片。
@Scheduled(fixedDelay = 30_000) public void compensatePayingOrders() { List<PayOrder> orders = orderMapper.findPayingTimeout(2, 100); for (PayOrder order : orders) { if (!lock.tryLock("pay:query:" + order.getOutTradeNo(), 10)) { continue; } try { QueryResult result = payClient.query(order.getOutTradeNo()); if ("SUCCESS".equals(result.getTradeState())) { orderService.markPaid(order.getOutTradeNo(), result.getTransactionId(), result.getAmountFen()); } else if ("CLOSED".equals(result.getTradeState())) { orderService.close(order.getOutTradeNo()); } } finally { lock.unlock("pay:query:" + order.getOutTradeNo()); } } }findPayingTimeout(2, 100)表示查 2 分钟前进入PAYING的订单,每次最多 100 条。锁的粒度按订单号,避免不同订单互相阻塞。markPaid内部仍然走状态机和金额校验,不能因为是补偿就跳过。查到SUCCESS但金额不一致时,不要直接改PAID,记录差异并告警。补偿任务还要有失败计数,连续失败多次的订单进入人工处理队列,避免无限重试把日志打爆。
4. 退款与对账:把资金链路收口到可审计
支付链路跑通后,退款和对账才是长期维护的分水岭。退款不是把订单状态改成REFUNDED就完事,它涉及退款单、部分退款、退款回调、原订单状态和财务对账。常见做法是每笔退款生成独立退款单,退款单号全局唯一,退款金额累计不能超过原订单金额;原订单状态只表示支付状态,退款状态由退款单承载。这样即使一笔订单多次部分退款,也能逐笔追踪,不会把原订单改成四不像。
4.1 退款申请的参数与幂等策略
退款申请要带原订单号、退款单号、退款金额、退款原因、回调地址。退款金额单位同样是分,部分退款时累加校验。幂等靠退款单号唯一键,重复提交同一退款单号直接返回已有结果,不要重复调支付平台。常见翻车是用户连点退款按钮,后端每次生成新退款单号,结果退了两笔。正确做法是前端传业务退款请求号,后端用这个号做唯一键,或者后端按原订单号加退款批次生成稳定单号。
| 参数 | 含义 | 设置建议 |
|---|---|---|
out_trade_no | 原商户订单号 | 必须已支付 |
out_refund_no | 商户退款单号 | 全局唯一,幂等键 |
amount.refund | 退款金额 | 分,累计不超过原订单 |
amount.total | 原订单金额 | 分,必须一致 |
notify_url | 退款回调地址 | 可与支付回调分开 |
@Transactional public RefundResult refund(String outTradeNo, String outRefundNo, long refundFen) { PayOrder order = orderMapper.selectByOutTradeNoForUpdate(outTradeNo); if (order == null || !"PAID".equals(order.getStatus())) { throw new IllegalStateException("订单不可退款"); } long refunded = refundMapper.sumSuccessRefundFen(outTradeNo); if (refunded + refundFen > order.getAmountFen()) { throw new IllegalArgumentException("退款金额超过原订单"); } RefundOrder refund = new RefundOrder(); refund.setOutRefundNo(outRefundNo); refund.setOutTradeNo(outTradeNo); refund.setRefundFen(refundFen); refund.setStatus("REFUNDING"); try { refundMapper.insert(refund); } catch (DuplicateKeyException e) { return refundMapper.selectByOutRefundNo(outRefundNo).toResult(); } RefundResponse resp = payClient.refund(outTradeNo, outRefundNo, refundFen, order.getAmountFen()); refund.setPlatformRefundId(resp.getRefundId()); refundMapper.updatePlatformId(outRefundNo, resp.getRefundId()); return refund.toResult(); }先锁订单行,再校验已退金额,再插退款单。唯一键冲突说明重复请求,直接返回已有退款单。调支付平台退款放在事务里要谨慎,如果远程调用成功但本地事务回滚,会出现平台已退款、本地没记录。更稳的做法是本地先落REFUNDING退款单并提交,再异步调退款接口,失败由补偿任务重试。上面的写法适合退款量不大的系统,量大时改成异步。refundFen必须大于 0,amount.total用原订单金额,不要用剩余可退金额。
4.2 退款回调与订单反向状态
退款回调验签逻辑和支付回调一致,但业务处理要更新退款单状态,而不是直接把原订单改成REFUNDED。原订单可以标记“部分退款”或“全额退款”的汇总状态,但退款明细以退款单为准。退款回调也要幂等,用退款单号加通知类型做唯一键。退款成功后如果业务需要回滚权益,比如扣回积分、关闭服务,用本地消息表异步做,不要在回调事务里同步调多个服务。
public void handleRefundNotify(String outRefundNo, String refundId, long refundFen) { RefundOrder refund = refundMapper.selectByOutRefundNo(outRefundNo); if (refund == null) { throw new IllegalStateException("退款单不存在"); } if ("SUCCESS".equals(refund.getStatus())) { return; } if (refund.getRefundFen() != refundFen) { throw new IllegalStateException("退款金额不一致"); } int updated = refundMapper.markSuccess(outRefundNo, refundId, refundFen); if (updated == 1) { localMessageMapper.insert("REFUND_SUCCESS", outRefundNo); } }markSuccess的 SQL 要带status = 'REFUNDING'条件,防止重复更新。金额校验不能省,回调金额和退款单金额不一致时告警。localMessageMapper.insert和退款单更新在同一个事务里,后续消费者根据消息做权益回滚。原订单的汇总状态可以按退款单统计后更新,但不要把原订单的支付流水号清掉,财务对账还要用。
4.3 对账文件下载与差异定位
对账是支付链路的最后一道审计。常见做法是每天定时下载前一天的账单文件,解析后和本地支付、退款流水逐笔比对。差异类型一般分四种:本地有、平台无;平台有、本地无;金额不一致;状态不一致。前两种多半是掉单或重复单,金额不一致要查退款和手续费,状态不一致要查回调丢失。对账文件不要覆盖历史,按日期归档,解析失败要保留原文件。
| 差异类型 | 常见原因 | 处理方式 |
|---|---|---|
| 本地有、平台无 | 下单未支付或本地伪造 | 查下单日志,关闭无效单 |
| 平台有、本地无 | 回调丢失、补偿未跑 | 主动查单补状态 |
| 金额不一致 | 退款、手续费、币种 | 拉退款流水核对 |
| 状态不一致 | 重复通知、乱序 | 按状态机修正并告警 |
public void reconcile(LocalDate date) throws IOException { Path file = billClient.download(date); List<BillRow> rows = billParser.parse(file); Set<String> platformOrderNos = new HashSet<>(); for (BillRow row : rows) { platformOrderNos.add(row.getOutTradeNo()); PayOrder local = orderMapper.selectByOutTradeNo(row.getOutTradeNo()); if (local == null) { diffMapper.insert("PLATFORM_ONLY", row.getOutTradeNo(), row.getAmountFen()); continue; } if (local.getAmountFen() != row.getAmountFen()) { diffMapper.insert("AMOUNT_DIFF", row.getOutTradeNo(), row.getAmountFen()); } if (!local.getStatus().equals(row.getStatus())) { diffMapper.insert("STATUS_DIFF", row.getOutTradeNo(), row.getAmountFen()); } } for (PayOrder local : orderMapper.findPaidByDate(date)) { if (!platformOrderNos.contains(local.getOutTradeNo())) { diffMapper.insert("LOCAL_ONLY", local.getOutTradeNo(), local.getAmountFen()); } } }下载和解析要幂等,同一天重复跑对账先清空当天差异表再写入,或者用日期加订单号做唯一键。解析时注意文件编码和分隔符,金额字段转long分,不要用Double。差异表要能按类型统计,第二天早上先看PLATFORM_ONLY和AMOUNT_DIFF,这两类通常最紧急。对账不只是财务的事,它能把回调丢失和状态乱序暴露出来。
4.4 日志与脱敏:排查时看什么
支付日志要能串起一次请求,但绝不能把私钥、完整签名、证书内容、用户证件号打进去。常见做法是给每个请求生成traceId,日志里带outTradeNo、transactionId、outRefundNo、状态、金额分、HTTP 状态码、错误码。签名只打前 8 位和后 8 位,body 里的手机号、姓名做脱敏。回调日志要区分“验签失败”“业务处理失败”“重复通知”,这三种的排查方向完全不同。
public String mask(String value) { if (value == null || value.length() <= 8) { return "***"; } return value.substring(0, 4) + "****" + value.substring(value.length() - 4); } log.info("pay notify traceId={} outTradeNo={} state={} amount={} httpStatus={}", traceId, outTradeNo, state, amountFen, httpStatus);traceId从网关或过滤器传入,异步线程要手动传递。金额打amountFen,不要打元的小数,避免日志里出现精度误导。错误码和错误信息要原样记录,但响应体里的敏感字段先脱敏。排查顺序一般是:先看回调有没有到、验签是否通过,再看业务事务是否提交,最后看状态机是否拒绝迁移。日志字段固定后,排查不用靠猜。
5. 排查与避坑:五条血泪经验对应的现象、原因和解决
支付链路的坑大多不是语法错误,而是“看起来成功、实际没落账”。下面五条是接入 H5 支付时反复出现的翻车点,每条按现象、原因、解决写,能直接对照日志排查。血泪经验是:先把回调入口和状态机做稳,再调前端跳转,顺序反了会浪费很多时间。
5.1 回调 200 但订单没变
现象:支付平台显示通知成功,本地订单还是PAYING,用户已经扣款。原因通常是回调控制器捕获了业务异常但返回 200,或者业务事务回滚了但应答已经发出。还有一种情况是验签通过后解析 JSON 失败,异常被全局异常处理器转成 200 空响应。解决:回调入口只在“验签通过 + 业务处理提交成功”后返回成功;业务失败返回 500 让平台重试;全局异常处理器对回调路径单独处理,不能把异常吞成 200。
try { notifyService.handle(body); return ResponseEntity.ok(successBody()); } catch (BizException e) { log.error("notify biz fail traceId={}", traceId, e); return ResponseEntity.status(500).body(failBody()); }注意返回体格式要符合接口要求,成功和失败的结构不能混。回调路径不要加登录鉴权,但要做签名验签和来源 IP 白名单(如果平台提供)。重试不是无限的,超过平台重试次数后必须靠主动查单补偿。
5.2 签名通过但下单报参数错误
现象:Authorization头没报签名失败,接口返回参数错误。原因多半是字段名大小写、嵌套结构不对、金额传了小数、scene_info缺字段。H5 支付的h5_info.type在不同场景下取值不同,缺了会报错;amount.total传1.00而不是100也会报错。解决:把实际发送的 body 原样打到脱敏日志,逐字段对照参数表;用固定的最小请求体先跑通,再逐步加字段;金额在入口统一转分。
long fen = new BigDecimal(amountYuan) .movePointRight(2) .setScale(0, RoundingMode.HALF_UP) .longValueExact();movePointRight(2)后必须setScale(0),不要直接longValue()截断。longValueExact()在溢出时抛异常,比静默截断安全。参数错误不要靠猜,把平台返回的错误码和错误字段记下来,下次直接查表。
5.3 金额单位与精度翻车
现象:订单金额 0.01 元,平台收到 0 分;或者退款金额多了一分。原因是用double做元转分,二进制浮点误差导致0.01 * 100 = 0.999999...,强转long变成 0。解决:所有金额入口用BigDecimal或字符串,转分用movePointRight(2).setScale(0, RoundingMode.HALF_UP);数据库存BIGINT分;接口出参再除 100 并保留两位。不要在前端做金额计算后传给后端,前端只传商品和数量,后端算钱。
public long yuanToFen(String yuan) { return new BigDecimal(yuan) .movePointRight(2) .setScale(0, RoundingMode.HALF_UP) .longValueExact(); }退款累计校验也用分,sumSuccessRefundFen返回long,不要返回BigDecimal再比较。对账文件里的金额字段同样按分解析,遇到小数先确认文件单位。
5.4 证书过期与平台证书轮换
现象:昨天还能下单,今天全部签名失败或回调验签失败。原因可能是商户证书过期,或者平台证书轮换后本地还缓存旧证书。解决:启动时校验商户证书有效期,提前 30 天告警;平台证书按序列号缓存,支持同时加载新旧两张,收到未知序列号时触发刷新;回调验签失败日志里打印序列号,不要只打“验签失败”。证书刷新要有频率限制,避免被伪造请求打爆刷新接口。
public PublicKey getPublicKey(String serial) { PublicKey key = cache.get(serial); if (key != null) { return key; } refreshPlatformCerts(); key = cache.get(serial); if (key == null) { throw new IllegalStateException("未知平台证书序列号: " + serial); } return key; }刷新后要把新证书写入缓存,旧证书保留一段时间,等确认没有旧通知再清理。证书文件不要放在可公开访问的静态目录,权限设为仅应用用户可读。
5.5 重复通知导致重复发货
现象:同一笔订单发了两次货,或者加了两次积分。原因是回调处理没有幂等,重复通知每次都能更新订单并触发发货。解决:通知流水唯一键 + 订单状态条件更新 + 发货走本地消息表。发货消费者用业务唯一键(订单号 + 发货类型)去重,不能只靠“查一下有没有发过”再插入,并发下会同时查到没有。
INSERT INTO delivery_task (out_trade_no, type, status) VALUES (?, 'GOODS', 'INIT') ON DUPLICATE KEY UPDATE status = status;唯一键是(out_trade_no, type)。插入成功才投递消息,插入冲突说明已处理。发货接口本身也要幂等,外部库存系统拒绝重复请求时记录结果,不要反复重试。
6. 进阶验证:用对账和压测把 H5 支付链路跑穿
上线前我会做两件事:一是本地模拟回调,把验签、解密、状态机、重复通知全部跑一遍;二是对回调入口做一次短压测,看限流和事务边界会不会被打穿。模拟回调不是伪造支付,而是用测试证书和固定报文验证代码路径,能提前发现“验签通过但解析失败”“重复通知重复发货”这类问题。压测也不是追求高并发,而是确认回调入口在突发重试下不会把数据库连接池占满。
6.1 本地模拟回调与验签的验证方法
本地准备一对测试密钥,私钥用来生成签名,公钥配到证书管理器里。构造固定 body,按回调签名串拼时间戳、随机串、body,生成签名后调本地接口。重点验四个分支:签名正确且业务成功返回成功;签名错误返回 401;业务异常返回 500;同一通知重复调用只落一条流水。测试 body 里带out_trade_no和金额,金额和本地订单不一致时要触发告警而不是改状态。
@Test void callback_should_be_idempotent() throws Exception { String body = "{\"out_trade_no\":\"T1001\",\"amount\":{\"total\":100}}"; String ts = String.valueOf(Instant.now().getEpochSecond()); String nonce = "abc123"; String message = ts + "\n" + nonce + "\n" + body + "\n"; String sign = sign(message, testPrivateKey); mockMvc.perform(post("/pay/notify") .contentType(MediaType.APPLICATION_JSON) .header("wechatpay-timestamp", ts) .header("wechatpay-nonce", nonce) .header("wechatpay-signature", sign) .header("wechatpay-serial", testSerial) .content(body)) .andExpect(status().isOk()); mockMvc.perform(post("/pay/notify") .contentType(MediaType.APPLICATION_JSON) .header("wechatpay-timestamp", ts) .header("wechatpay-nonce", nonce) .header("wechatpay-signature", sign) .header("wechatpay-serial", testSerial) .content(body)) .andExpect(status().isOk()); assertEquals(1, notifyLogMapper.countByOutTradeNo("T1001")); }测试里签名和请求头必须和真实回调一致,wechatpay-serial用测试证书序列号。第二次调用断言流水只有一条,订单状态只迁移一次。业务异常分支单独写测试,断言返回 500 而不是 200。
6.2 压测与限流:别把回调入口打穿
回调入口压测用短脚本即可,重点是看数据库连接池、事务耗时和重复通知下的唯一键冲突。脚本里不要打真实网关,只打本地回调接口,签名提前生成或每次生成都行。并发不要一上来几百,先 20 并发跑 1 分钟,看错误率和 P99。回调入口要有限流,按来源或订单号维度都行,超过阈值返回 429 让平台重试,不要直接丢弃。
for i in $(seq 1 200); do curl -s -o /dev/null -w "%{http_code}\n" \ -X POST "http://127.0.0.1:8080/pay/notify" \ -H "Content-Type: application/json" \ -H "wechatpay-timestamp: $TS" \ -H "wechatpay-nonce: $NONCE" \ -H "wechatpay-signature: $SIGN" \ -H "wechatpay-serial: $SERIAL" \ --data "$BODY" & done wait脚本里TS、NONCE、SIGN、BODY提前设好,压测同一笔订单能直接验证幂等。看结果时关注 500 和 401 的比例,500 多说明业务事务或数据库扛不住,401 多说明签名串或证书有问题。限流阈值按平台重试节奏设置,别把正常重试也限掉。
6.3 上线前检查表
| 检查项 | 通过标准 |
|---|---|
| 私钥与证书 | 不在代码仓库,启动校验有效期 |
| 回调验签 | 原始 body 验签,失败返回非 200 |
| 状态机 | 非法迁移拒绝并告警 |
| 幂等 | 重复通知只落一条流水、只发一次货 |
| 金额 | 全链路用分,入口 BigDecimal 转换 |
| 补偿 | 超时订单能主动查单并修正状态 |
| 对账 | 差异表能按类型统计并告警 |
| 日志 | 有 traceId,敏感字段脱敏 |
从那以后我每次接 H5 支付都强制先跑一遍回调验签和重复通知,再谈前端跳转;对账和补偿没跑通之前,不敢让订单进入生产。希望帮到你。
本文还有配套的精品资源,点击获取