☰
Spring Boot对接微信商家转账到零钱:幂等回调与对账
2026/11/2 6:57:16 网站建设 项目流程

简介:面向Java后台开发者的微信企业付款到零钱功能实现参考,聚焦企业向员工发放工资、奖金或处理退款等场景,帮助开发者打通商户平台配置、支付接口调用与安全校验的完整链路。压缩包为rar格式,共2个Java文件,体积约3KB,分别承载转账请求的控制器逻辑与签名工具方法,代码短小但覆盖参数组装、证书与密钥使用、返回状态判断、异步回调处理及日志记录等关键环节,便于直接嵌入现有项目或作为二次开发的基础骨架。已有3089人学习下载,说明其在微信支付转账类需求中具备一定参考价值。读者可从中了解企业付款API的调用顺序、签名生成思路与异常分支的处理方式,并据此梳理权限控制与测试环境验证的落地路径,适合具备一定Java与微信支付基础、需要快速完成转账模块搭建的中级开发者查阅。

1. 一笔提现卡在「处理中」的下午:Java 后台对接微信企业转账到零钱到底在解决什么

A 同学所在的小程序上线了提现功能,用户点「立即提现」,后端调 Java 后台微信企业转账到零钱接口,日志里明明打印了成功,钱却没进用户零钱,订单状态一直挂在「处理中」,客服电话被打爆。扒了半天日志才发现,他看的是 HTTP 200,而微信支付返回的业务字段里还有一层状态码,异步通知根本没接。这个项目干的就是把这类破事收拾干净:一个基于 Spring Boot 的后台服务,接收业务侧出款单,做幂等落库,调起小程序 AppID 下的商家转账接口,接回调、做验签、跑主动查询兜底,最后落到对账。适合正在做提现、佣金发放、活动返现的后端同学,也适合想把出款能力沉淀成公共模块的团队。

2. 工程骨架与技术选型:APIv3、证书、回调三件套怎么摆

这套代码的目录结构没有做过度分层,原因是出款链路本身不长,但每一步都不能糊。真正需要拆细的是「跟微信支付交互」这一层,业务逻辑反而应该薄,因为它以后大概率会被挪进公共出款模块。

2.1 目录结构与模块职责

项目是一个标准的 Spring Boot 单体,Maven 多模块留了扩展位,但单模块也能跑。目录大致长这样:

pay-transfer ├── src/main/java/com/example/transfer │ ├── TransferApplication.java # 启动类 │ ├── config/ │ │ ├── WxPayProperties.java # 商户号、AppID、证书路径、APIv3 密钥 │ │ └── WxPayClientConfig.java # HttpClient、平台证书缓存、定时刷新 │ ├── controller/ │ │ ├── TransferController.java # 业务侧发起转账的入口 │ │ └── TransferNotifyController.java # 微信支付回调入口 │ ├── service/ │ │ ├── TransferService.java # 下单、调微信、更新状态 │ │ ├── TransferQueryService.java # 主动查询 + 补偿 │ │ └── TransferStateMachine.java # 状态流转的唯一出口 │ ├── domain/ │ │ ├── TransferOrder.java # 转账单实体 │ │ └── TransferStatus.java # 枚举 │ ├── mapper/TransferOrderMapper.java │ └── wx/ │ ├── WxPaySigner.java # 请求签名 │ ├── WxPayVerifier.java # 回调验签 + 报文解密 │ └── WxPayHttpClient.java # 统一发请求、统一打日志 ├── src/main/resources/ │ ├── application.yml │ └── cert/apiclient_key.pem # 商户 API 私钥,不进 Git └── pom.xml

wx这个包是整份代码里唯一值得反复读的部分。WxPayHttpClient把所有对外请求收口到一个方法里:拼 Authorization 头、序列化 body、记录请求流水号和耗时、把响应原文落一份到t_wx_pay_log表。出款出问题的时候,这份日志就是唯一的现场证据,没有它只能靠猜。

业务侧只认TransferService.create()一个方法,传进来的是内部单号、openid、金额(分)、收款人姓名(可空),返回的是本地转账单状态。业务方不关心微信那套签名细节,这个边界划清楚,后面接别的出款渠道才不会烂。

2.2 两套接口的取舍:老的企业付款到零钱与新商家转账

代码里同时留了 v2 和 v3 两套实现,但默认走 v3。原因写在下面这张表里,实际选型时对着看就行:

维度企业付款到零钱(v2)商家转账到零钱(v3)
请求地址/mmpaymkttransfers/promotion/transfers/v3/transfer/batches
证书双向证书,必须带 PKCS12商户私钥签名 + 平台证书验签
报文格式XMLJSON
敏感信息明文user_nameuser_name必须 RSA-OAEP 加密
回调XML 通知,MD5/HMAC 签名JSON 通知,Wechatpay-Signature头验签
场景报备无必须传transfer_scene_id,商户平台已报备

v2 省事,但敏感字段裸奔、签名弱,新商户号开通时基本都会被引导到 v3。注意一点:v3 的transfer_scene_id不是随便填的,必须跟商户平台里报备过的转账场景对上,对不上直接报「转账场景未报备」,这个错误在本地自测阶段非常容易撞上。

2.3 配置项与密钥、证书的存放

配置文件里的东西分三类:能进 Git 的、不能进 Git 的、可以放配置中心的。

wxpay: mch-id: "1900000000" # 商户号,和 AppID 有绑定关系 app-id: "wx0000000000000000" # 小程序 AppID,openid 必须属于它 merchant-serial-no: "5A1B2C3D..." # 商户 API 证书序列号,不是证书文件名 private-key-path: "classpath:cert/apiclient_key.pem" api-v3-key: "32位随机串" # 回调报文解密用,长度必须 32 notify-url: "https://your-domain.com/notify/transfer" transfer-scene-id: "1005" # 报备过的转账场景 connect-timeout-ms: 3000 read-timeout-ms: 10000

商户私钥和 APIv3 密钥绝对不能进代码仓库,常见做法是打进配置中心或者挂在容器的 secret 目录,启动时读文件路径。merchant-serial-no是最容易抄错的一项,很多人顺手把证书里的序列号复制成文件名,结果签名一直报「签名错误」。APIv3 密钥一旦改过,历史回调就解不开了,所以这个值变更要当发布事故来处理,新旧密钥同时保留一段时间更稳妥。

3. 核心链路拆解:一笔转账从发起请求到回调落库

这条链路上有四个必须自己写死的点:幂等键、签名串、敏感字段加密、状态机。任何一个漏掉,线上都会以「钱发了但单子没更新」的形式炸给你看。

3.1 转账单表设计与幂等键

先落库再调接口,顺序不能反。

CREATE TABLE t_transfer_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, out_batch_no VARCHAR(64) NOT NULL COMMENT '商户批次单号,全局唯一', out_detail_no VARCHAR(64) NOT NULL COMMENT '商户明细单号,幂等键', app_id VARCHAR(32) NOT NULL, mch_id VARCHAR(32) NOT NULL, openid VARCHAR(64) NOT NULL COMMENT '必须属于上面 app_id', amount_fen INT NOT NULL COMMENT '单位:分', user_name_enc VARCHAR(512) DEFAULT NULL COMMENT '收款人姓名密文,不回填明文', scene_id VARCHAR(16) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'INIT' COMMENT 'INIT/PROCESSING/SUCCESS/FAIL/CLOSED', wx_batch_id VARCHAR(64) DEFAULT NULL, wx_detail_id VARCHAR(64) DEFAULT NULL, fail_reason VARCHAR(256) DEFAULT NULL, retry_count TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_detail_no (out_detail_no), KEY idx_status_ctime (status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

out_detail_no上的唯一索引就是整套幂等的底座。用户手抖连点两次,第二次插入直接撞唯一键,业务层捕获DuplicateKeyException后查出已有单据返回,压根不会打到微信。金额一律存分,用INT不用DECIMAL,避免任何浮点参与。user_name_enc存密文,查单、对账、客服排查都用不上明文,省得给自己找麻烦。

状态机收在一个枚举 + 一个方法里,所有状态变更必须走它:

public TransferStatus next(TransferStatus current, WxResult result) { // 只允许单向流转,已经成功的单子不允许被任何回调改回处理中 if (current == TransferStatus.SUCCESS || current == TransferStatus.CLOSED) { return current; } if (result.isSuccess()) { return TransferStatus.SUCCESS; } // 系统繁忙、频率超限这类属于可重试,保持处理中 return result.isRetryable() ? TransferStatus.PROCESSING : TransferStatus.FAIL; }

把「已终态不回头」这条规则焊死在代码里,能挡掉回调乱序带来的一大批脏数据。

3.2 请求签名:五行待签名串,一个换行都不能少

v3 的签名规则写得清楚,翻车的都在细节上。

public final class WxPaySigner { private static final String SCHEMA = "WECHATPAY2-SHA256-RSA2048"; /** * @param method HTTP 方法,必须大写 * @param urlPath 带 query 的路径,例如 /v3/transfer/batches?limit=10 * @param body 请求体原文,GET 请求传空串 */ public static String build(String mchId, String serialNo, PrivateKey key, String method, String urlPath, String body) throws Exception { long timestamp = System.currentTimeMillis() / 1000; // 秒级,不是毫秒 String nonce = RandomStringUtils.randomAlphanumeric(32); // 待签名串固定五行,每行以 \n 结尾,最后一行也要有 String message = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"; Signature signer = Signature.getInstance("SHA256withRSA"); signer.initSign(key); signer.update(message.getBytes(StandardCharsets.UTF_8)); String signature = Base64.getEncoder().encodeToString(signer.sign()); return SCHEMA + " mchid=\"" + mchId + "\"" + ",nonce_str=\"" + nonce + "\"" + ",signature=\"" + signature + "\"" + ",timestamp=\"" + timestamp + "\"" + ",serial_no=\"" + serialNo + "\""; } }

三个参数要盯死:urlPath带 query 一起签,少了?后面那段必挂;body必须和真正发出去的字节完全一致,中间过一遍 JSON 重新序列化就废了;serial_no是商户 API 证书序列号,不是平台证书序列号,这俩在不同请求头里用,混了就是「签名验证失败」。

3.3 敏感字段加密与 openid 归属校验

收款人姓名要走 RSA-OAEP 用平台证书公钥加密,且加密结果必须用同一次请求的证书版本。平台证书会轮换,所以客户端里要维护一份证书列表按Wechatpay-Serial匹配。

private String encryptUserName(String userName) { try { Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding"); // 用当前生效的平台证书公钥,不是商户证书 cipher.init(Cipher.ENCRYPT_MODE, platformCert.getPublicKey()); return Base64.getEncoder() .encodeToString(cipher.doFinal(userName.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException("收款人姓名加密失败", e); } }

openid 的校验放在调接口之前,属于纯本地能做的检查:openid 必须来自同一个 AppID 的小程序授权,跨 AppID 直接拒绝。库里建一张t_user_openid映射表,记录用户 ID、AppID、openid 三者的绑定关系,下单时反查一次,查不到就返回「用户未授权」。这一步能拦掉相当一部分「openid 参数错误」的线上问题。

3.4 发起转账与回调验签

发起方法本身不长,重点在异常分支的归类。

public TransferOrder create(TransferCmd cmd) { TransferOrder order = buildOrder(cmd); try { // 唯一索引挡重复提交 transferOrderMapper.insert(order); } catch (DuplicateKeyException e) { return transferOrderMapper.selectByDetailNo(cmd.getOutDetailNo()); } Map<String, Object> detail = new HashMap<>(); detail.put("out_detail_no", order.getOutDetailNo()); detail.put("transfer_amount", order.getAmountFen()); // 单位分 detail.put("transfer_remark", "用户提现"); detail.put("openid", order.getOpenid()); if (StringUtils.isNotBlank(cmd.getUserName())) { detail.put("user_name", encryptUserName(cmd.getUserName())); } Map<String, Object> body = new HashMap<>(); body.put("appid", properties.getAppId()); body.put("out_batch_no", order.getOutBatchNo()); body.put("batch_name", "用户提现"); body.put("batch_remark", "提现打款"); body.put("total_amount", order.getAmountFen()); body.put("total_num", 1); body.put("transfer_scene_id", properties.getTransferSceneId()); body.put("transfer_detail_list", Collections.singletonList(detail)); WxResult result = wxPayHttpClient.post("/v3/transfer/batches", body); // 同步只落「已受理」,最终结果一律以回调/查单为准 stateMachine.moveTo(order, result.isAccepted() ? TransferStatus.PROCESSING : TransferStatus.FAIL, result); return order; }

stateMachine.moveTo里对PROCESSING之外的返回值都写fail_reason,方便客服查。同步返回成功只代表微信受理了批次,不代表钱到账,这一点必须写在接口注释里,否则接手的人一定会误用。

回调这边先验签再解密,顺序反了等于白验。

@PostMapping("/notify/transfer") public Map<String, String> notify(@RequestBody String body, @RequestHeader("Wechatpay-Signature") String signature, @RequestHeader("Wechatpay-Timestamp") String timestamp, @RequestHeader("Wechatpay-Nonce") String nonce, @RequestHeader("Wechatpay-Serial") String serial) { // 验签串:timestamp\n nonce\n body\n if (!wxPayVerifier.verify(timestamp, nonce, body, serial, signature)) { return fail("验签失败"); } // 报文用 APIv3 密钥做 AES-256-GCM 解密 String plain = wxPayVerifier.decrypt(body); // 里面才有 out_detail_no 和真实状态 TransferNotify notify = JSON.parseObject(plain, TransferNotify.class); transferService.handleNotify(notify); // 成功必须返回这个结构,否则微信会持续重推 return Collections.singletonMap("code", "SUCCESS"); }

handleNotify内部按out_detail_no查单,如果本地已经是终态就直接返回成功、不做任何更新,这就是回调重复推送的兜底。微信的通知是「至少一次」,不是「恰好一次」,接回调时脑子里要有这根弦。

3.5 主动查询与对账补偿

回调会丢,尤其是本地服务重启、网关超时的时候。所以必须有一个定时任务,扫PROCESSING且超过 2 分钟的单子去查。

@Scheduled(fixedDelay = 120_000) public void compensate() { List<TransferOrder> list = transferOrderMapper .selectProcessingBefore(LocalDateTime.now().minusMinutes(2), 200); for (TransferOrder order : list) { // 按明细单号查,比按批次查更精准 String path = "/v3/transfer/batches/out-batch-no/" + order.getOutBatchNo() + "/details/out-detail-no/" + order.getOutDetailNo(); WxResult r = wxPayHttpClient.get(path); if (r.isNotFound()) { // 微信侧压根没有这笔,说明当时请求没发出去,可以直接置失败放行重试 stateMachine.moveTo(order, TransferStatus.FAIL, r); } else if (r.isFinal()) { stateMachine.moveTo(order, TransferStatus.SUCCESS, r); } // 其余情况保持处理中,下一轮再查 } }

频率别调太低,微信侧对查询接口有频控;也别太高,200 条一批、两分钟一轮在中小体量下够用。真正的兜底是每天跟微信的对账单做一次全量比对,把「本地成功、微信失败」和「微信成功、本地处理中」两类差异单独拉出来人工处理,这一步没法用代码完全消掉。

4. 常见避坑与排查:证书、金额、openid 三类翻车现场

这一章全部来自实际排查记录,每条都按「现象 → 原因 → 解决」写。

4.1 证书与签名类

现象:所有请求都返回 401,提示「签名错误」,但私钥文件名、路径都对。
原因:serial_no填成了平台证书序列号,或者复制了证书文件名。商户 API 证书序列号和平台证书序列号是两套东西,请求头里用的必须是商户 API 证书的序列号。
解决:在商户平台重新下载证书时把序列号记到配置里,启动时打印一次加载到的序列号和私钥指纹,比对无误再往下调。

现象:本地跑得好好的,发到服务器就 401,且只在大额单子上出现。
原因:私钥文件在打包时被 Maven 的资源过滤改动了换行符,导致 PEM 解析出的公钥跟证书对不上;小单子走的是另一份缓存过的客户端实例,所以看起来「时好时坏」。
解决:私钥文件放resources/cert并在pom.xml里给这个目录配<filtering>false</filtering>,启动时做一次签名自校验:用私钥签一段固定字符串,再用证书公钥验一遍,失败直接启动报错。

现象:回调一直验签失败,但手工用工具验是通的。
原因:验签用的body被 Spring 的@RequestBody String之外的东西消费过一次,或者中间加了过滤器读取了流,导致拿到的字节和微信发来的不一致。
解决:在网关或 Filter 层用ContentCachingRequestWrapper缓存请求体,验签只认原始字节,绝不重新序列化。

4.2 业务参数类

现象:返回「openid 与 appid 不匹配」。
原因:用户是从公众号或另一个小程序授权来的 openid,AppID 对不上。openid 是 AppID 维度的标识,不是用户维度的。
解决:下单前查绑定表,按 AppID 反查 openid;小程序端授权时就把 AppID 一起带上,落库时建立映射。

现象:金额总是少一位,用户提现 10 元到账 0.1 元。
原因:业务侧传的是元,转账接口要的是分,中间的BigDecimal转int用了intValue()直接截断。
解决:统一在入口处用amount.multiply(BigDecimal.valueOf(100)).intValueExact(),转不了就抛异常,绝不允许静默截断。

现象:报「转账场景未报备」。
原因:transfer_scene_id用了别人的示例值,或者商户平台还没通过场景报备。
解决:去商户平台确认报备状态,把拿到的场景 ID 写进配置;测试环境可以用报备通过的那一个,别在代码里硬编码。

4.3 回调与状态类

现象:用户收到钱了,本地订单还是「处理中」,对账时天天出现差异单。
原因:回调地址配的是内网地址,微信打不进来;或者回调处理抛异常返回了 500,微信重推若干次后停止。
解决:回调地址必须是公网可达的 HTTPS,处理逻辑包 try-catch,异常也要返回{"code":"SUCCESS"}让微信停止重推,把失败单交给定时查询兜底。

现象:同一笔单子状态在「成功」和「处理中」之间反复横跳。
原因:查单和回调并发更新,用的是「先查再改」,没有加乐观锁。
解决:更新语句带上version和状态条件,UPDATE ... WHERE id=? AND status='PROCESSING' AND version=?,影响行数为 0 就放弃本次更新。

5. 跑起来与联调:本地环境到第一笔成功打款

拿到代码之后先别急着改业务,按下面的顺序把环境跑通,能省掉大量来回。

5.1 本地启动步骤

依赖只有 MySQL、Redis 和 JDK。Redis 主要用来做转账频控和幂等短锁,没有也能跑,但并发场景下建议开着。

# 1. 建库建表 mysql -uroot -p -e "CREATE DATABASE pay_transfer DEFAULT CHARSET utf8mb4;" mysql -uroot -p pay_transfer < doc/schema.sql # 2. 放入证书(不要提交到仓库) mkdir -p src/main/resources/cert cp ~/Downloads/apiclient_key.pem src/main/resources/cert/ # 3. 本地配置改成自己的商户信息 vim src/main/resources/application-local.yml # 4. 启动 mvn clean spring-boot:run -Dspring-boot.run.profiles=local

启动日志里应该能看到三行关键信息:加载到的商户证书序列号、平台证书刷新成功、回调地址。这三行缺一行都别往下走。本地调试回调需要公网可达地址,常见做法是用内网穿透工具把本地 8080 映射出去,把映射后的地址填进notify-url,但注意这个地址必须跟商户平台配置的一致,否则微信会拒发。

5.2 联调与错误码定位

第一笔测试单建议用最小金额,先把「受理成功 → 回调成功」整条链路走通再谈别的。

现象大概率原因看哪里
401 签名错误序列号、私钥、签名串换行WxPaySigner打印的待签名串
403 权限不足商户号未开通商家转账商户平台产品中心
400 参数错误金额单位、场景 ID、openid请求体原文日志
回调验签失败body 被改写、平台证书过期t_wx_pay_log里的原始报文
一直处理中回调没进来、查单任务未生效定时任务日志 + 查单返回码

排查时优先看t_wx_pay_log表,里面存了请求 path、请求体、响应体、耗时和Wechatpay-Request-ID。跟微信侧沟通时只要报这个 ID,对方能直接定位到那一次请求,比自己描述半天管用得多。日志里的user_name记得脱敏,密文也别往日志里打。

6. 二次开发:把转账逻辑抽成可复用的出款中台

单项目跑通之后,下一步基本都是「别的业务线也要发钱」。这时候别复制粘贴,抽一层出款接口更划算。

public interface PayoutChannel { String code(); // WX_TRANSFER / ALIPAY_TRANSFER PayoutResult payout(PayoutCmd cmd); // 发起出款 PayoutResult query(String outBizNo); // 主动查询 boolean supports(String scene); // 是否支持该场景 }

微信实现类里包住前面那套 Service,业务方只依赖PayoutChannel。再加一个简单的路由和频控:同一用户 1 分钟内只允许发起一笔,同一商户号每秒请求数做个令牌桶。这套结构下新增一个渠道,改动量基本就是一个实现类加一段配置。

改造点落地方式收益
多渠道路由按scene选实现类换渠道不动业务代码
出款限额Redis 令牌桶 + 单日累计挡住异常大额与刷单
对账每日拉对账单落表比对差异单可追溯
幂等出款单号唯一索引 + 状态机重试不再产生重复打款

验证方法很直接:写一个压测用例,同一out_detail_no并发打 50 次,检查库里只有一条记录、微信侧只收到一次请求;再把回调接口人为置为超时,观察定时任务能否在几分钟内把状态补齐。

我在第一次接这个接口时,图省事把同步返回当成了最终结果,结果一批单子在「成功」和「处理中」之间来回跳,对账那天熬到凌晨三点。从那以后我每次接出款类接口,都强制走一遍「落库 → 调接口 → 回调 → 定时查单 → 对账」这条全链路,少一环都不敢上线。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询