代码 微信支付 V3 商家转账到零钱:从 V2 迁过来,卡住的不只是 API 地址

2026-09-12 21:32:10

微信支付 V3 商家转账到零钱:从 V2 迁过来,卡住的不只是 API 地址

2025-01-15 微信支付上线了新版「商家转账」(/v3/fund-app/mch-transfer/...),旧的「商家转账到零钱」(/v3/transfer/batches)需升级重接,本文按旧版接口实战整理。

官方接口文档:官方接口文档
商户平台:商户平台

从 V2 升到 V3,不是换 API 地址和参数格式就结束。V3 全面转向基于非对称加密的 APIv3 密钥和证书体系,和 V2 的 MD5 或 HMAC-SHA256 签名方式不同。开发环境能过的配置,生产环境可能报“访问IP不在白名单之中”或“证书验签失败”。

2. 核心概念与前期准备:理解 V3 接口的安全基石

2.1 APIv3 密钥、商户 API 证书与商户私钥:三角关系

  • APIv3 密钥:在商户平台(pay.weixin.qq.com)设置的 32 位到 64 位字符串。它不是用来做请求签名的,而是用于解密回调通知和加密敏感信息(如银行卡号)。在【账户中心】->【API安全】->【APIv3密钥】里设置。微信只保存其哈希值,一旦丢失无法找回,只能重置,重置会导致所有依赖此密钥的回调功能中断。
  • 商户 API 证书.pem 格式的公钥证书文件。作用是让微信支付服务器验证你的身份。调用接口时用商户私钥对请求签名,并把签名和证书序列号一起传给微信支付,微信支付用你证书里的公钥验签。在【账户中心】->【API安全】->【API证书】申请下载。
  • 商户私钥:与商户 API 证书配对的私钥文件。申请证书时由证书生成工具本地生成,形态为 apiclient_key.pem(PKCS#1)或 apiclient_key.p12(PKCS#12,含私钥和证书链)。最高机密,不能泄露或提交到代码仓库,用来生成请求签名。

关键理解:APIv3 密钥用于“解密”,是对称加密的密钥;商户证书和私钥是“签名/验签”,属于非对称加密。两者用途完全不同,但都是必须的。

2.2 IP 白名单:第一道防火墙

准入门槛。所有调用微信支付 API 的服务器 IP 必须预先在商户平台配置,否则报“访问IP不在白名单之中”。

配置位置:【账户中心】->【API安全】->【IP白名单】。需要配置后端业务服务器的公网 IP,即实际发起调用的那台机器的 IP。

常见坑:

  • 开发环境:开发机在内网无固定公网 IP,可暂配公司出口 IP 范围,或用 ngrok 等内网穿透拿临时 IP 测试,生产必须固定。
  • 生产环境:云服务器直接填弹性公网 IP;用了负载均衡(SLB)填负载均衡公网 IP;容器化通过 NodePort/Ingress 对外需找到最终承载流量节点的公网 IP。
  • 多实例/弹性伸缩:IP 会变,需将服务部署在固定出口 IP 的 NAT 网关之后,或用云厂商“固定公网 IP/EIP”绑定计算单元。

2.3 证书与私钥的文件格式:PEM vs P12

  • apiclient_cert.pem:商户 API 证书(公钥),用于验签。
  • apiclient_key.pem:商户私钥(PKCS#1 格式),文本形式,以 -----BEGIN PRIVATE KEY----- 开头。
  • apiclient_cert.p12:包含私钥和证书链的 PKCS#12 二进制文件,通常有密码(默认商户号)。Java 等语言更常用。
  • rootca.pem 等:微信支付根证书和中间证书,用于构建信任链。

Python、PHP、Node.js 用 apiclient_key.pem + apiclient_cert.pem 这对最直接;Java 生态对 P12 支持更友好。

3. 完整配置流程实操

3.1 步骤一:商户平台关键配置

  • 登录商户平台。
  • 设置 APIv3 密钥:进入【账户中心】->【API安全】->【APIv3密钥】,点“设置密钥”,输入足够复杂的字符串(可用 openssl rand -base64 32 生成),记下来存密码管理器。
  • 申请并下载 API 证书:【账户中心】->【API安全】->【API证书】,点“申请证书”,会要求下载“证书生成工具”并在本地运行生成私钥和 CSR。工具运行后会生成 apiclient_key.pem 和一个请求串,把请求串粘回商户平台生成证书,再下载证书包(ZIP)。安全警告:生成的 apiclient_key.pem 立即转移到非代码目录(如 /etc/wechatpay/),chmod 600,绝对不要放进代码仓库。
  • 配置 IP 白名单:【账户中心】->【API安全】->【IP白名单】,点“添加IP”,输入服务器公网 IP(服务器上 curl ifconfig.mecurl ip.sb 获取),可加多个,换行分隔。

3.2 步骤二:服务器环境与文件准备

假设项目部署在 /data/app/your-project

# 1. 创建专用证书目录,只有当前用户可读
sudo mkdir -p /etc/wechatpay/certs
sudo chown your-app-user:your-app-group /etc/wechatpay/certs
sudo chmod 700 /etc/wechatpay/certs

# 2. 上传证书包到服务器临时位置
scp ./WXCert.zip your-user@your-server:/tmp/

# 3. 解压并移动到安全目录
unzip /tmp/WXCert.zip -d /tmp/wechat_cert
sudo mv /tmp/wechat_cert/apiclient_cert.pem /etc/wechatpay/certs/
sudo mv /tmp/wechat_cert/apiclient_key.pem /etc/wechatpay/certs/
sudo mv /tmp/wechat_cert/rootca.pem /etc/wechatpay/certs/

# 4. 设置严格权限
sudo chmod 644 /etc/wechatpay/certs/apiclient_cert.pem
sudo chmod 600 /etc/wechatpay/certs/apiclient_key.pem
sudo chown your-app-user:your-app-group /etc/wechatpay/certs/*.pem

# 5. 清理临时文件
rm -rf /tmp/wechat_cert /tmp/WXCert.zip

3.3 步骤三:代码集成与关键逻辑实现(Python 为例,requests + cryptography)

pip install requests cryptography

工具类核心:__init__ 加载私钥;_make_signature 生成 V3 接口 Authorization 签名;request 发起带签名请求;transfer_to_balance 调商家转账到零钱。

签名串构造注意:

  • URL 去掉协议头和域名,从路径开始(如 /v3/transfer/batches)。
  • body 为紧凑 JSON(无多余空格换行),POST 空对象也要作为字符串 "{}" 参与签名,GET 的 body 是空字符串。
  • 每一行后都有换行符 \n,最后一行也要有。
message = f"{method}\n{url}\n{timestamp}\n{nonce_str}\n{body}\n"

用私钥 SHA256 with RSA 签名,Base64 编码。

Authorization 头:

WECHATPAY2-SHA256-RSA2048 mchid="...",serial_no="...",nonce_str="...",timestamp="...",signature="..."

证书序列号可用 get_serial_no_from_cert.pem 解析,微信要求的是十进制字符串,非十六进制,可预取保存到配置。

调用示例:POST /v3/transfer/batches,data 含 appidout_batch_nobatch_namebatch_remarktotal_amount(分)、total_numtransfer_detail_list

import base64
import json
import time
import uuid
from pathlib import Path

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.x509 import load_pem_x509_certificate


class WechatPayV3:
    def __init__(self, mchid, appid, cert_path, key_path, api_v3_key):
        self.mchid = mchid
        self.appid = appid
        self.api_v3_key = api_v3_key
        self.private_key = serialization.load_pem_private_key(
            Path(key_path).read_bytes(), password=None
        )
        self.serial_no = self._get_serial_no_from_cert(cert_path)

    @staticmethod
    def _get_serial_no_from_cert(cert_path):
        cert = load_pem_x509_certificate(Path(cert_path).read_bytes())
        return str(cert.serial_number)

    def _make_signature(self, method, url, body):
        timestamp = str(int(time.time()))
        nonce_str = uuid.uuid4().hex
        message = f"{method}\n{url}\n{timestamp}\n{nonce_str}\n{body}\n"
        signature = base64.b64encode(
            self.private_key.sign(
                message.encode("utf-8"),
                padding.PKCS1v15(),
                hashes.SHA256(),
            )
        ).decode("utf-8")
        auth = (
            'WECHATPAY2-SHA256-RSA2048 '
            f'mchid="{self.mchid}",'
            f'serial_no="{self.serial_no}",'
            f'nonce_str="{nonce_str}",'
            f'timestamp="{timestamp}",'
            f'signature="{signature}"'
        )
        return auth

    def request(self, method, url, body_dict=None):
        body = (
            json.dumps(body_dict or {}, separators=(",", ":"))
            if method != "GET"
            else ""
        )
        auth = self._make_signature(method.upper(), url, body)
        headers = {
            "Authorization": auth,
            "Accept": "application/json",
            "Content-Type": "application/json",
        }
        return requests.request(
            method.upper(),
            f"https://api.mch.weixin.qq.com{url}",
            headers=headers,
            data=body,
        )

    def transfer_to_balance(self, out_batch_no, total_amount, total_num, transfer_detail_list):
        url = "/v3/transfer/batches"
        data = {
            "appid": self.appid,
            "out_batch_no": out_batch_no,
            "batch_name": "转账",
            "batch_remark": "转账",
            "total_amount": total_amount,
            "total_num": total_num,
            "transfer_detail_list": transfer_detail_list,
        }
        return self.request("POST", url, data)

3.4 步骤四:处理回调通知(Webhook)

V3 回调使用 AEAD_AES_256_GCM 加密,用 APIv3 密钥解密。ciphertext 是 Base64 编码的,AESGCM.decrypt(nonce, ciphertext_bytes, associated_data)

流程:

  1. 获取 Wechatpay-Serial/Signature/Timestamp/Nonce 头。
  2. 可选但推荐验证签名来源(用平台证书 https://api.mch.weixin.qq.com/v3/certificates)。
  3. 解密请求体。
  4. 解析 JSON,取 resourceevent_type(如 TRANSFER.SUCCESS/TRANSFER.FAIL)、summarybatch_idout_batch_no,更新业务状态。
  5. 必须返回 HTTP 200(如 {"code":"SUCCESS","message":"OK"}),否则微信会重试。
import base64
import json
from cryptography.hazmat.primitives.ciphers.aead import AESGCM


def decrypt_resource(api_v3_key, resource):
    aesgcm = AESGCM(api_v3_key.encode("utf-8"))
    plaintext = aesgcm.decrypt(
        resource["nonce"].encode("utf-8"),
        base64.b64decode(resource["ciphertext"]),
        resource["associated_data"].encode("utf-8"),
    )
    return json.loads(plaintext)

4. 避坑指南与疑难杂症排查

4.1 “访问IP不在白名单之中”

核对白名单 IP 有无多余空格换行;服务器 curl ifconfig.me / cip.cc 看实际出口 IP;云上前面有负载均衡/NAT/CDN/代理时,出口 IP 是这些设备的 IP,可写临时接口返回 REMOTE_ADDR,从公网访问确认;多网卡多 IP 要绑对网卡。解决:把正确出口 IP 加进白名单,复杂架构找运维确认。

4.2 “证书验签失败”或“无效的签名”

  • 检查证书序列号(十进制,与 apiclient_cert.pem 一致)。
  • 检查私钥文件(以 -----BEGIN PRIVATE KEY----- 开头、未损坏、应用有读权限)。
  • 检查签名构造(HTTP 方法大写、URL 为绝对路径不含域名协议、body 为紧凑 JSON、每行含换行符)。
  • 时间戳同步(NTP,误差超 5 分钟会拒绝)。
  • 私钥格式(PKCS#1 vs PKCS#8 需密码)。

调试技巧:打印 sign_message 字符串和 Base64 前的签名,用商户平台签名验证工具或在线 RSA 验签工具用公钥证书验证。

4.3 “此商家的收款功能已被限制,暂无法支付”

与转账功能本身无关,是商户号被风控。可能原因:新商户未完成实名/资质审核;异常交易被风控;appid 与商户号绑定关系有问题或未开通支付权限。解决:查商户号状态;查【产品中心】->【我的产品】是否开通“商家转账到零钱”;确认 appid 是绑定的、已开通支付的公众号/小程序 APPID;仍不行联系微信支付客服申诉。

4.4 P12 证书使用(Java)

KeyStore.getInstance("PKCS12"),load 时密码默认商户号,取别名后 getKeyPrivateKey,证书序列号 getSerialNumber().toString(10) 取十进制。

KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (InputStream is = Files.newInputStream(Paths.get("/etc/wechatpay/certs/apiclient_cert.p12"))) {
    keyStore.load(is, mchid.toCharArray()); // 默认密码为商户号
}
String alias = keyStore.aliases().nextElement();
PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, mchid.toCharArray());
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
String serialNo = cert.getSerialNumber().toString(10);

注意 P12 同样要保护好;V3 官方 wechatpay-java SDK 或社区 binarywang Java SDK 已封装细节,建议直接用成熟 SDK。

4.5 证书过期与轮换

商户 API 证书有效期通常一年,过期前微信会站内信/邮件通知。到期前 1 个月申请新证书,更新服务器文件,注意证书序列号变了需更新代码或配置里的 serial_no,灰度更新重启,验证新证书正常后下线旧证书。轮换期间确保 APIv3 密钥未变更,否则影响回调解密。

5. 安全最佳实践与上线检查清单

5.1 安全红线

  • 私钥/证书绝不入仓:apiclient_key.pem.p12、含它们的 ZIP 都不能提交 Git,用 Vault/Ansible Vault 管理。
  • 最小权限:证书文件 600,仅属主可读写,运行进程用户可读。
  • APIv3 密钥保密:从环境变量或配置中心读取,勿硬编码。
  • 回调接口必须验签防伪造。
  • 网络隔离。

5.2 上线前检查清单

  • 商户平台:APIv3 密钥已设置并正确记录;API 证书已申请且私钥安全保存;服务器出口 IP 已加入白名单;“商家转账到零钱”产品已开通。
  • 服务器与文件:证书私钥已上传至安全目录;权限 600(私钥)/644(证书);应用运行用户有读权限。
  • 代码与配置:mchidAPPIDserial_no 正确;APIv3 密钥安全注入;签名逻辑与官方验证工具结果一致;回调解密已实现测试;异常处理齐备(网络超时、签名错误、解密失败)。
  • 端到端测试:测试环境用 1 分钱走通;模拟成功和失败回调确认业务逻辑;检查数据库状态更新与日志。

官方接口文档:https://pay.weixin.qq.com/doc/v3/merchant/4012065168

复制全文 生成海报 微信支付 APIv3 商家转账 证书 回调

推荐文章

程序员茄子在线接单