【微信支付V3】商家转账回调 (上手即用 亲测有效 避踩坑)

亲测有效 开箱即用 

前言

在接入微信支付V3的过程中,很多开发者都会遇到一个关键问题:如何正确处理“商家转账到零钱”功能的异步回调通知?

尤其是在生产环境中,由于网络波动、签名验证失败、参数缺失等问题,常常导致回调通知无法正常接收或解析。本文将带你一步步梳理微信支付V3中“商家转账”功能的回调机制,并提供完整的回调处理逻辑代码片段(以Node.js为例),帮助你快速实现安全可靠的回调处理流程。

✅ 本文适用于已有微信支付V3商户平台接入经验的开发者
🧪 所有示例均已通过实际测试,可直接用于项目部署
🔐 包含完整签名验证、数据解密、异步通知处理等核心流程


一、什么是“商家转账到零钱”?

“商家转账到零钱”是微信支付为企业用户提供的资金划拨服务,允许商户向指定的微信用户零钱账户进行转账操作。该功能常用于以下场景:

  • 发放佣金、奖励、红包
  • 退款至用户余额
  • 内部员工薪资发放
  • 合作伙伴之间的资金结算

核心特点:

  • 支持单笔/批量转账
  • 资金实时到账
  • 提供详细的转账记录与对账能力
  • 支持异步回调通知,便于系统自动处理后续业务逻辑

二、为什么需要处理回调通知?

当商户发起一笔转账请求后,微信支付平台并不会立即返回最终结果(例如用户是否成功收款)。为了确保业务系统的准确性与完整性,微信提供了异步回调通知机制

回调通知会在以下情况下触发:

  • 转账成功
  • 转账失败(如用户拒收、账户异常等)
  • 用户主动退还资金

因此,正确处理回调通知是保障系统状态同步、避免重复转账、及时更新订单状态的关键环节。


三、回调通知的核心流程概述

  1. 配置回调地址(notify_url)

    • 在调用微信转账接口时传入 notify_url
    • 微信将在转账状态变更时向该地址发送POST请求
  2. 接收并校验回调请求

    • 验证请求来源合法性(通过HTTP头中的 Wechatpay-Signature 和 Wechatpay-Timestamp
    • 使用商户私钥对签名进行验证,防止伪造请求
  3. 解密回调数据

    • 回调体为加密数据(通常为AES-GCM算法加密)
    • 需使用商户私钥对应的APIv3密钥进行解密
  4. 处理业务逻辑

    • 解析出转账结果(成功/失败)
    • 更新数据库状态、触发后续动作(如发送通知、记录日志等)
  5. 返回响应

    • 成功处理后需返回 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,
					});
				}
			},

然后就可以拉起用户确认收款 就大功告成啦!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值