亲测有效 开箱即用
前言
在接入微信支付V3的过程中,很多开发者都会遇到一个关键问题:如何正确处理“商家转账到零钱”功能的异步回调通知?
尤其是在生产环境中,由于网络波动、签名验证失败、参数缺失等问题,常常导致回调通知无法正常接收或解析。本文将带你一步步梳理微信支付V3中“商家转账”功能的回调机制,并提供完整的回调处理逻辑代码片段(以Node.js为例),帮助你快速实现安全可靠的回调处理流程。
✅ 本文适用于已有微信支付V3商户平台接入经验的开发者
🧪 所有示例均已通过实际测试,可直接用于项目部署
🔐 包含完整签名验证、数据解密、异步通知处理等核心流程
一、什么是“商家转账到零钱”?
“商家转账到零钱”是微信支付为企业用户提供的资金划拨服务,允许商户向指定的微信用户零钱账户进行转账操作。该功能常用于以下场景:
- 发放佣金、奖励、红包
- 退款至用户余额
- 内部员工薪资发放
- 合作伙伴之间的资金结算
核心特点:
- 支持单笔/批量转账
- 资金实时到账
- 提供详细的转账记录与对账能力
- 支持异步回调通知,便于系统自动处理后续业务逻辑
二、为什么需要处理回调通知?
当商户发起一笔转账请求后,微信支付平台并不会立即返回最终结果(例如用户是否成功收款)。为了确保业务系统的准确性与完整性,微信提供了异步回调通知机制。
回调通知会在以下情况下触发:
- 转账成功
- 转账失败(如用户拒收、账户异常等)
- 用户主动退还资金
因此,正确处理回调通知是保障系统状态同步、避免重复转账、及时更新订单状态的关键环节。
三、回调通知的核心流程概述
-
配置回调地址(notify_url)
- 在调用微信转账接口时传入
notify_url
- 微信将在转账状态变更时向该地址发送POST请求
- 在调用微信转账接口时传入
-
接收并校验回调请求
- 验证请求来源合法性(通过HTTP头中的
Wechatpay-Signature
和Wechatpay-Timestamp
) - 使用商户私钥对签名进行验证,防止伪造请求
- 验证请求来源合法性(通过HTTP头中的
-
解密回调数据
- 回调体为加密数据(通常为AES-GCM算法加密)
- 需使用商户私钥对应的APIv3密钥进行解密
-
处理业务逻辑
- 解析出转账结果(成功/失败)
- 更新数据库状态、触发后续动作(如发送通知、记录日志等)
-
返回响应
- 成功处理后需返回 HTTP 200 状态码,否则微信会重复推送通知
四、回调通知处理详解
1. 接收回调请求
微信在转账状态变更时,会向你在调用接口时传入的 notify_url
发送一个 POST 请求。
请求头(Headers)包含以下关键字段:
Header 字段 | 描述 |
---|---|
Wechatpay-Signature | 签名值,用于验证请求来源合法性 |
Wechatpay-Serial | 微信平台证书序列号,用于定位当前签名使用的证书 |
Wechatpay-Timestamp | 时间戳,用于防止重放攻击 |
Wechatpay-Nonce | 随机字符串,用于防止重放攻击 |
请求体(Body)为加密数据
五、回调通知处理代码 上手即用
前提是 发起商家转账api的notify_url的值要对应自己服务器微信回调的地址 精确到controller实体方法上
1. 导入springboot maven依赖
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-java</artifactId>
<version>0.2.15</version>
</dependency>
2.导入实体类 注意换package
import com.google.gson.annotations.SerializedName;
import com.wechat.pay.java.core.cipher.PrivacyDecryptor;
import lombok.Data;
import java.util.Objects;
/**
* 商户单号查询转账单实体类信息
*
* @author: suhai
* @date: 2025/05/13 11:28
*/
@Data
public class TransferDetailEntityNew {
/** 商户号 Y 说明:微信支付分配的商户号 */
@SerializedName("mch_id")
private String mchId;
/** 商户单号 Y 说明:商户系统内部的商家单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一 */
@SerializedName("out_bill_no")
private String outBillNo;
/** 商家转账订单号 Y 说明:商家转账订单的主键,唯一定义此资源的标识 */
@SerializedName("transfer_bill_no")
private String transferBillNo;
/** 商户appid Y 说明:申请商户号的appid或商户号绑定的appid(企业号corpid即为此appid) */
@SerializedName("appid")
private String appid;
/** 单据状态 Y 说明:单据状态 */
@SerializedName("state")
private String state;
/** 转账金额 Y 说明:转账金额单位为“分”。*/
@SerializedName("transfer_amount")
private Integer transferAmount;
/** 转账备注 Y 说明:转账备注,用户收款时可见该备注信息,UTF8编码,最多允许32个字符。*/
@SerializedName("transfer_remark")
private String transferRemark;
/** 失败原因 N 说明:订单已失败或者已退资金时,返回失败原因。 */
@SerializedName("fail_reason")
private String failReason;
/** 收款用户OpenID Y 说明:商户AppID下,某用户的OpenID */
@SerializedName("openid")
private String openid;
/** 收款用户姓名 N 说明:收款方真实姓名。需要加密传入,支持标准RSA算法和国密算法,公钥由微信侧提供。
转账金额 >= 2,000元时,该笔明细必须填写
若商户传入收款用户姓名,微信支付会校验收款用户与输入姓名是否一致,并提供电子回单 */
@SerializedName("user_name")
private String userName;
/** 单据创建时间 N 说明:单据受理成功时返回,按照使用rfc3339所定义的格式,格式为yyyy-MM-DDThh:mm:ss+TIMEZONE */
@SerializedName("create_time")
private String createTime;
/** 最后一次状态变更时间 N 说明:单据最后更新时间,按照使用rfc3339所定义的格式,格式为yyyy-MM-DDThh:mm:ss+TIMEZONE */
@SerializedName("update_time")
private String updateTime;
public String getMchId() {
return mchId;
}
public void setMchId(String mchId) {
this.mchId = mchId;
}
public String getOutBillNo() {
return outBillNo;
}
public void setOutBillNo(String outBillNo) {
this.outBillNo = outBillNo;
}
public String getTransferBillNo() {
return transferBillNo;
}
public void setTransferBillNo(String transferBillNo) {
this.transferBillNo = transferBillNo;
}
public String getAppid() {
return appid;
}
public void setAppid(String appid) {
this.appid = appid;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
public Integer getTransferAmount() {
return transferAmount;
}
public void setTransferAmount(Integer transferAmount) {
this.transferAmount = transferAmount;
}
public String getTransferRemark() {
return transferRemark;
}
public void setTransferRemark(String transferRemark) {
this.transferRemark = transferRemark;
}
public String getFailReason() {
return failReason;
}
public void setFailReason(String failReason) {
this.failReason = failReason;
}
public String getOpenid() {
return openid;
}
public void setOpenid(String openid) {
this.openid = openid;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getCreateTime() {
return createTime;
}
public void setCreateTime(String createTime) {
this.createTime = createTime;
}
public String getUpdateTime() {
return updateTime;
}
public void setUpdateTime(String updateTime) {
this.updateTime = updateTime;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
TransferDetailEntityNew that = (TransferDetailEntityNew) o;
return Objects.equals(mchId, that.mchId) && Objects.equals(outBillNo, that.outBillNo) && Objects.equals(transferBillNo, that.transferBillNo) && Objects.equals(appid, that.appid) && Objects.equals(state, that.state) && Objects.equals(transferAmount, that.transferAmount) && Objects.equals(transferRemark, that.transferRemark) && Objects.equals(failReason, that.failReason) && Objects.equals(openid, that.openid) && Objects.equals(userName, that.userName) && Objects.equals(createTime, that.createTime) && Objects.equals(updateTime, that.updateTime);
}
@Override
public int hashCode() {
return Objects.hash(mchId, outBillNo, transferBillNo, appid, state, transferAmount, transferRemark, failReason, openid, userName, createTime, updateTime);
}
@Override
public String toString() {
return "TransferDetailEntityNew{" +
"mchId='" + mchId + '\'' +
", outBillNo='" + outBillNo + '\'' +
", transferBillNo='" + transferBillNo + '\'' +
", appid='" + appid + '\'' +
", state='" + state + '\'' +
", transferAmount=" + transferAmount +
", transferRemark='" + transferRemark + '\'' +
", failReason='" + failReason + '\'' +
", openid='" + openid + '\'' +
", userName='" + userName + '\'' +
", createTime='" + createTime + '\'' +
", updateTime='" + updateTime + '\'' +
'}';
}
public TransferDetailEntityNew cloneWithCipher(PrivacyDecryptor encryptor) {
TransferDetailEntityNew copy = new TransferDetailEntityNew();
copy.mchId = mchId;
copy.outBillNo = outBillNo;
copy.transferBillNo = transferBillNo;
copy.appid = appid;
copy.state = state;
copy.transferAmount = transferAmount;
copy.transferRemark = transferRemark;
copy.failReason = failReason;
copy.openid = openid;
if (userName != null && !userName.isEmpty()) {
copy.userName = encryptor.decrypt(userName);
}
copy.createTime = createTime;
copy.updateTime = updateTime;
return copy;
}
}
3.导入controller
@PostMapping(value = "/wechat/transferNotify")如果新让微信回调到
综上所述发起微信转账的notify_url要写自己服务器 https 域名 端口 /wechat/transferNotify的路径
/**
* 微信商户零线转账 - 回调通知
* @Context注解 把HTTP请求上下文对象注入进来,HttpServletRequest、HttpServletResponse、UriInfo 等
* @return
*/
@PostMapping(value = "/wechat/transferNotify")
public ResponseEntity<TransferDetailEntityNew> wxPayCallback(@Context HttpServletRequest request) {
Map<String,String> errMap = new HashMap<>();
try {
log.info("微信商户零线转账 - 回调通知 /wxpay/callback");
TransferDetailEntityNew entity = wxPaySuccessCallback(request);
log.info("transfer ok.{}",entity);
//回调成功后处理自己的业务
if(entity != null){
}
return new ResponseEntity<>(entity, HttpStatus.OK);
} catch (Exception e) {
log.error("微信商户零线转账 - 回调通知 /wxpay/callback:异常!", e);
errMap.put("code", "FAIL");
errMap.put("message", "服务器内部错误");
return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
4.导入wxPaySuccessCallback方法
这里需要注意的是privateKeyFromPath是自己服务器下商户API证书私钥的存放路径;
publicKeyFromPath是商户API证书公钥的存放路径;
public TransferDetailEntityNew wxPaySuccessCallback(HttpServletRequest request) {
String requestBody = getBodyString(request, "UTF-8");
//证书序列号(微信平台) 验签的“微信支付平台证书”所对应的平台证书序列号
String wechatPaySerial = request.getHeader("Wechatpay-Serial");
//微信传递过来的签名 验签的签名值
String wechatSignature = request.getHeader("Wechatpay-Signature");
//验签的时间戳
String wechatTimestamp = request.getHeader("Wechatpay-Timestamp");
//验签的随机字符串
String wechatpayNonce = request.getHeader("Wechatpay-Nonce");
// 1. 构造 RequestParam
RequestParam requestParam = new RequestParam.Builder()
.serialNumber(wechatPaySerial)
.nonce(wechatpayNonce)
.signature(wechatSignature)
.timestamp(wechatTimestamp)
.body(requestBody)
.build();
// 2. 构建Config RSAPublicKeyConfig
Config config =
new RSAPublicKeyConfig.Builder()
.merchantId("") //微信支付的商户号
.privateKeyFromPath("/xx/xx/apiclient_key.pem") // 商户API证书私钥的存放路径
.publicKeyFromPath("/xx/xx/pub_key.pem") //微信支付公钥的存放路径
.publicKeyId("") //微信支付公钥ID
.merchantSerialNumber("") //商户API证书序列号
.apiV3Key("") //APIv3密钥
.build();
log.info("WxPayService.wxPaySuccessCallback request : wechatPaySerial is [{}] , wechatSignature is [{}] , wechatTimestamp is [{}] , wechatpayNonce is [{}] , requestBody is [{}]",wechatPaySerial,wechatSignature,wechatTimestamp,wechatpayNonce,requestBody);
// 3. 初始化 NotificationParser
NotificationParser parser = new NotificationParser((NotificationConfig) config);
try {
TransferDetailEntityNew entity = parser.parse(requestParam, TransferDetailEntityNew.class);
log.info("WxPayService.wxPaySuccessCallback responseBody: {}", entity != null ? JSON.toJSONString(entity) : null);
return entity;
} catch (Exception e) {
log.error("Exception occurred while processing", e);
throw new SubstituteException("系统内部错误");
}
}
5.导入 获取post请求中的Body
/**
* 获取post请求中的Body
*
* @param request httpRequest
* @return body字符串
*/
public static String getBodyString(HttpServletRequest request, String charSet) {
StringBuilder sb = new StringBuilder();
InputStream inputStream = null;
BufferedReader reader = null;
try {
inputStream = request.getInputStream();
//读取流并将流写出去,避免数据流中断;
reader = new BufferedReader(new InputStreamReader(inputStream, charSet));
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
} catch (IOException e) {
log.error("获取requestBody异常", e);
} finally {
IoUtil.close(inputStream);
IoUtil.close(reader);
}
return sb.toString();
}
六、2025年最新的商家转账 额度为200 并且需要用户手动确认收款
1.小程序调用商户主动发起向用户转账
tx(item) {
if (wx.canIUse('requestMerchantTransfer')) {
wx.requestMerchantTransfer({
mchId: ' ',
appId: ' ',
package: item,
success: (res) => {
// res.err_msg将在页面展示成功后返回应用时返回ok,并不代表付款成功
console.log('success:', res);
this.getList('upper');
},
fail: (res) => {
console.log('fail:', res);
},
});
} else {
wx.showModal({
content: '你的微信版本过低,请更新至最新版本。',
showCancel: false,
});
}
},
然后就可以拉起用户确认收款 就大功告成啦!!!