微信支付 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.me或curl 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 含 appid、out_batch_no、batch_name、batch_remark、total_amount(分)、total_num、transfer_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)。
流程:
- 获取
Wechatpay-Serial/Signature/Timestamp/Nonce头。 - 可选但推荐验证签名来源(用平台证书
https://api.mch.weixin.qq.com/v3/certificates)。 - 解密请求体。
- 解析 JSON,取
resource、event_type(如TRANSFER.SUCCESS/TRANSFER.FAIL)、summary、batch_id、out_batch_no,更新业务状态。 - 必须返回 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 时密码默认商户号,取别名后 getKey 拿 PrivateKey,证书序列号 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(证书);应用运行用户有读权限。
- 代码与配置:
mchid、APPID、serial_no正确;APIv3 密钥安全注入;签名逻辑与官方验证工具结果一致;回调解密已实现测试;异常处理齐备(网络超时、签名错误、解密失败)。 - 端到端测试:测试环境用 1 分钱走通;模拟成功和失败回调确认业务逻辑;检查数据库状态更新与日志。