简介:本资源是一套面向PHP开发者与微信支付接入工程师的实战型代码包,聚焦小程序用户提交记录后商家后台自动审核并打款至微信零钱的核心业务场景,解决企业付款到零钱(v2密钥)与微信商户转账到零钱(v3密钥)双版本接口的落地难题。压缩包共3个文件(2个PHP核心控制器文件分别实现v2/v3接口调用逻辑,1个说明文档梳理密钥配置、签名规则与调用要点),总大小仅4KB,轻量易集成,适合中高级开发者快速复用或对照调试。已有1318人学习下载,资源直接提取自真实项目,提供可运行的Demo级代码结构、关键参数封装示例及v2/v3迁移注意事项,尤其适合正在对接微信打款功能、需规避签名失效或接口废弃风险的商户系统开发人员。
1. 微信商户付款到零钱:v2密钥版和v3密钥版不是“升级替换”,而是两套并行、互不兼容的独立通道——选错版本,连签名都验不过
你手头有一份「微信商户付款到微信用户零钱」的源码包,标题里同时标着 v2密钥版 和 v3密钥版。别急着解压运行——这不是一个项目两个分支,而是两条完全不同的技术路径:v2 基于 MD5 + API 密钥签名,走的是 2014 年上线的老支付网关;v3 基于 RSA-SHA256 + 平台证书验签,对接的是 2020 年起强制推行的新开放平台。我见过太多团队在测试环境跑通 v2,上线后切 v3 时卡在「INVALID_SIGNATURE」整整三天:不是代码写错了,是根本没意识到 v3 要求你先在商户平台下载平台证书、用私钥生成请求签名、再用公钥验签响应体——而 v2 根本不碰证书。这个源码包的价值,不在于它“有两套”,而在于它把两套最常翻车的落地细节全摊开了:v2 的 key 拼接顺序玄学、v3 的证书加载时机黑匣子、回调验签时 timestamp 和 nonce_str 的毫秒级对齐陷阱。适合正在接入微信零钱打款、已开通「企业付款」权限、但被文档绕晕的后端工程师——尤其适合那些刚被运营催着“今天必须打出第一笔测试款”的人。
2. v2密钥版:MD5签名不是拼接完就完事,key位置、字段顺序、空值处理三处不一致,签名必挂
v2 接口(https://api.mch.weixin.qq.com/mmpaymkttransfers/promotion/transfers)表面简单,实则处处是坑。它的签名逻辑是:将所有非空参数按字段名 ASCII 升序排序,拼成key1=value1&key2=value2&key3=value3&key=key字符串,再对整个字符串做 MD5。但“非空”“ASCII 升序”“key 的位置”这三点,微信官方文档写得极其模糊,而源码包里的 v2 实现正是踩过全部血泪坑后沉淀下来的。
2.1 参数组装与签名生成:必须严格遵循字段名 ASCII 排序,且 key 必须放在末尾
微信 v2 签名要求所有参与签名的字段(除sign外)按字段名字典序升序排列,且key=xxx必须追加在最后。注意:partner_trade_no、amount、check_name这些字段名本身是小写,但mch_appid、mchid是小写,nonce_str是小写,re_user_name是小写——它们的 ASCII 值决定了顺序。常见错误是把mchid放最前(因为觉得它是商户号),但amount(a)比mchid(m)靠前,所以amount必须排第一。
# python 示例:v2 签名生成函数(关键逻辑) import hashlib import urllib.parse def generate_v2_sign(params: dict, api_key: str) -> str: # 1. 过滤空值:值为 None、''、0 都要剔除(注意:0 是数字,不是空字符串,但微信要求剔除!) filtered = {k: v for k, v in params.items() if v is not None and v != '' and v != 0} # 2. 按 key 字典序升序排序(ASCII) sorted_items = sorted(filtered.items(), key=lambda x: x[0]) # 3. 拼接 k=v&k=v&...&key=xxx sign_str = '&'.join([f'{k}={v}' for k, v in sorted_items]) + f'&key={api_key}' # 4. MD5 大写 return hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()提示:
params字典传入前,务必确保amount是整数单位(分),partner_trade_no是纯数字或字母组合(不能含下划线),openid是标准微信 openid(32位小写字母+数字)。check_name必须是FORCE_CHECK或NO_CHECK,不能是force_check(大小写敏感)。
2.2 请求构造与 XML 封装:CDATA 包裹、编码、换行符一个都不能错
v2 接口只认application/xml,且要求 body 是标准 XML。微信对 XML 格式极其苛刻:所有业务字段值必须用<![CDATA[]]>包裹;XML 声明必须是<?xml version="1.0" encoding="UTF-8"?>;标签必须严格闭合;<xml>根节点内不能有多余换行或空格。
# 构造 v2 请求 XML body def build_v2_xml(params: dict, sign: str) -> str: xml_parts = ['<?xml version="1.0" encoding="UTF-8"?>', '<xml>'] for k, v in params.items(): # 所有值必须 CDATA 包裹,包括数字 xml_parts.append(f'<{k}><![CDATA[{v}]]></{k}>') xml_parts.append(f'<sign><![CDATA[{sign}]]></sign>') xml_parts.append('</xml>') return ''.join(xml_parts) # 使用示例 params = { 'mch_appid': 'wx1234567890abcdef', 'mchid': '1234567890', 'nonce_str': '5K8264ILTKCH16CQ2502SI8ZNMTM67VS', 'partner_trade_no': '20240520100001', 'openid': 'oAbcDefGhIjKlMnOpQrStUvWxYz', 'check_name': 'FORCE_CHECK', 're_user_name': '张三', 'amount': 100, # 1元 = 100分 'desc': '测试打款', 'spbill_create_ip': '127.0.0.1' } sign = generate_v2_sign(params, 'your_api_key_here') xml_body = build_v2_xml(params, sign) # 发送请求(requests 库) import requests response = requests.post( 'https://api.mch.weixin.qq.com/mmpaymkttransfers/promotion/transfers', data=xml_body.encode('utf-8'), headers={'Content-Type': 'application/xml'} )参数说明:
spbill_create_ip必须是真实发起请求的服务器公网 IP(不能是 127.0.0.1 或内网地址),否则返回INVALID_REQUEST;desc不能超过 30 个字符;re_user_name若含中文,XML 编码必须为 UTF-8,且<?xml ...?>声明中encoding="UTF-8"必须显式写出。
3. v3密钥版:RSA-SHA256 不是“换个算法”,而是整套通信模型重构——证书、签名、验签、回调四步缺一不可
v3 接口(https://api.mch.weixin.qq.com/v3/pay/transfer/batches)彻底抛弃了 v2 的 MD5 + key 模式,改用基于 X.509 证书的非对称加密体系。它不是“v2 的升级版”,而是一套新协议:你用私钥签名请求,微信用你的公钥验签;微信用平台私钥签名响应,你用平台公钥验签;所有敏感字段(如金额、收款人)必须 AES-256-GCM 加密;回调通知也必须验签+解密。源码包里的 v3 实现,核心价值在于把这四步的“证书加载时机”“签名头构造”“响应体解密”“回调验签链”全部拆解成了可调试的模块。
3.1 平台证书加载与私钥准备:证书不是“下载完放文件夹就行”,必须解析出公钥用于验签
v3 要求你从微信商户平台下载「平台证书」(.pem文件),该文件本质是 PEM 格式的 X.509 证书,里面包含微信的公钥。你不能直接拿.pem去验签,必须用 OpenSSL 或 Python 的cryptography库从中提取出公钥对象。同时,你的 APIv3 密钥(即你在商户平台设置的 32 位字符串)仅用于 AES 加密,不参与 RSA 签名——RSA 签名用的是你自己的私钥(.pem或.p12格式)。
# python 示例:加载平台证书并提取公钥(用于验签微信响应) from cryptography import x509 from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa def load_platform_public_key(cert_pem_path: str): with open(cert_pem_path, "rb") as f: cert_data = f.read() cert = x509.load_pem_x509_certificate(cert_data) return cert.public_key() # 加载你的私钥(用于签名请求) def load_merchant_private_key(key_pem_path: str, key_password: bytes = None): with open(key_pem_path, "rb") as f: key_data = f.read() return serialization.load_pem_private_key( key_data, password=key_password, backend=default_backend() )注意:微信平台证书有有效期(通常 2 年),且会轮换。源码包中应包含自动检测证书过期、触发重新下载的逻辑(通过调用
GET /v3/certificates接口)。不要硬编码证书路径,生产环境必须支持热更新。
3.2 请求签名与 Authorization 头构造:timestamp、nonce_str、body 三者哈希必须严格一致
v3 签名头Authorization: WECHATPAY2-SHA256-RSA2048 ...的生成是最大难点。它不是对参数签名,而是对「HTTP 方法 + 换行符 + 请求路径 + 换行符 + 时间戳 + 换行符 + 随机串 + 换行符 + 请求体 SHA256」这一整段字符串做 RSA-SHA256 签名。其中:
timestamp是当前 Unix 时间戳(秒级,非毫秒);nonce_str是 32 位随机字符串(字母+数字);body是原始 JSON 字符串(无空格、无换行、键名小写);- 三者拼接时,每个换行符
\n都是 LF(\x0A),不能是 CRLF(\x0D\x0A)。
# v3 签名头生成(简化版,实际需 base64 编码签名) import time import secrets import hashlib import base64 from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import hashes def generate_v3_authorization( method: str, url_path: str, timestamp: int, nonce_str: str, body: str, private_key ): # 1. 构造签名原串 message = f"{method}\n{url_path}\n{timestamp}\n{nonce_str}\n{body}\n" # 2. 计算 body 的 SHA256(注意:body 是原始 JSON 字符串,未格式化) body_hash = hashlib.sha256(body.encode('utf-8')).digest() # 3. 对 message 做 RSA-SHA256 签名 signature = private_key.sign( message.encode('utf-8'), padding.PKCS1v15(), hashes.SHA256() ) # 4. 构造 Authorization 头(实际需 base64 编码 signature) auth_header = ( f'WECHATPAY2-SHA256-RSA2048 ' f'mchid="{mchid}",' f'nonce_str="{nonce_str}",' f'signature="{base64.b64encode(signature).decode()}",' f'timestamp="{timestamp}",' f'serial_no="{serial_no}"' # serial_no 是你证书的序列号,需从证书中读取 ) return auth_header关键点:
serial_no不是你自己设的,而是你上传到微信的 API 证书的序列号(可在证书详情里看到,或用openssl x509 -in apiclient_cert.pem -noout -serial查看)。漏填或填错serial_no,微信直接返回401 Unauthorized。
4. v2 与 v3 的核心差异避坑:5 条血泪经验,每一条都让团队少加班 4 小时
这两套方案不是“选哪个更好”,而是“在什么场景下必须用哪个”。很多团队以为 v3 是“新标准所以必须切”,结果发现 v2 的到账速度更快、失败率更低;也有团队死磕 v2,直到微信发邮件通知“v2 接口将于 X 月下线”才仓促迁移。以下是我在 12 个微信打款项目中踩出的 5 条硬核避坑指南:
4.1 现象:v2 接口返回FAIL,result_code=FAIL,err_code=SIGN_ERROR
原因:签名字符串中混入了空格、制表符、不可见 Unicode 字符,或key没放在末尾,或amount传了字符串"100"而非整数100(v2 要求所有数字字段为整型,字符串会被当作空值过滤)。
解决:打印出最终拼接的sign_str,用repr()查看是否含\x00、\t、\r;确认params字典中amount、mchid等字段是int类型;用sorted(..., key=lambda x: x[0])强制 ASCII 排序,勿用dict.keys()默认顺序。
4.2 现象:v3 接口返回401 Unauthorized,响应体为空
原因:Authorization头中的serial_no错误(填了商户号、API 密钥、或旧证书序列号),或timestamp与微信服务器时间偏差超过 300 秒(微信校验时间戳容错为 ±5 分钟),或nonce_str重复使用(v3 要求每次请求唯一)。
解决:用openssl x509 -in apiclient_cert.pem -noout -serial精确获取序列号;请求前同步服务器时间(ntpdate -s time.windows.com);nonce_str必须用secrets.token_urlsafe(24)生成,禁止用uuid.uuid4().hex(长度不够且含-)。
4.3 现象:v3 成功创建批次,但查询明细返回{"code":"PARAM_ERROR","message":"invalid request"}
原因:查询接口GET /v3/pay/transfer/batches/{batch_id}/details?offset=0&limit=20的offset和limit参数必须是字符串"0"和"20",不能是整数0和20(微信 v3 API 对 query 参数类型极其敏感)。
解决:所有 query 参数统一转为字符串,URL 拼接时用urllib.parse.urlencode({'offset': '0', 'limit': '20'})。
4.4 现象:v2 打款成功,但用户零钱未到账,微信账单显示“企业付款”状态为“处理中”超 24 小时
原因:spbill_create_ip填了内网 IP(如192.168.x.x、10.x.x.x)或127.0.0.1,微信风控系统拦截。
解决:在发起请求的服务器上执行curl ifconfig.me获取真实出口 IP,并在商户平台「产品中心 > 企业付款 > IP 白名单」中添加该 IP(支持 CIDR,如203.208.60.0/24)。
4.5 现象:v3 回调通知验签失败,WechatPay-Signature头存在但验签报InvalidSignature
原因:回调体是 gzip 压缩的(微信默认开启),但你的服务未解压就直接验签;或回调体 JSON 中的resource.algorithm字段值为"AEAD_AES_256_GCM",但你的解密逻辑用了 AES-CBC;或resource.nonce和resource.ciphertext拼接时多加了换行符。
解决:收到回调后,先检查Content-Encoding: gzip头,用gzip.decompress()解压;再按微信文档解密流程:aes_key = hmac.new(api_v3_key.encode(), (associated_data + nonce).encode(), hashlib.sha256).digest()[:32],然后 AES-256-GCM 解密。
5. 生产环境必须做的三件事:证书轮换监控、异步结果轮询、失败自动重试策略
源码包给你的是“能跑通”,但生产环境要的是“不出事”。我在线上跑了 3 年微信打款,总结出三条铁律:不监控证书,等于裸奔;不轮询结果,等于盲打;不设计重试,等于放弃 SLA。
5.1 平台证书自动轮换:用定时任务每 24 小时检查证书有效期,过期前 7 天强制刷新
微信平台证书有效期 2 年,但会在到期前 30 天开始推送新证书。如果你不主动轮换,旧证书过期那一刻,所有 v3 接口瞬间 500。源码包中必须包含一个独立脚本,每天凌晨 2 点执行:
# check_cert.sh #!/bin/bash CERT_PATH="/opt/wechat/certs/platform_cert.pem" DAYS_LEFT=$(openssl x509 -in "$CERT_PATH" -noout -daysuntilexpire 2>/dev/null | awk '{print $1}') if [ "$DAYS_LEFT" -lt 7 ]; then echo "Certificate expires in $DAYS_LEFT days, fetching new one..." # 调用 v3 接口 GET /v3/certificates 获取新证书 curl -X GET \ -H "Authorization: Bearer $(generate_access_token)" \ "https://api.mch.weixin.qq.com/v3/certificates" \ -o /tmp/new_cert.pem # 验证新证书有效性 if openssl x509 -in /tmp/new_cert.pem -noout -subject >/dev/null 2>&1; then mv /tmp/new_cert.pem "$CERT_PATH" systemctl reload wechat-pay-service echo "Cert updated successfully" fi fi提示:
generate_access_token是用你的商户号、APIv3 密钥、私钥生成的短期 token(有效期 8 小时),必须缓存并自动续期。不要在每次请求时都生成新 token。
5.2 异步结果轮询:v3 批次创建后,必须用固定间隔轮询GET /v3/pay/transfer/batches/{batch_id},直到batch_status变为FINISHED
v3 打款是异步的。你调POST /v3/pay/transfer/batches只是提交申请,真正打款成功与否,要看batch_status字段。微信不保证回调 100% 到达(网络抖动、你的服务宕机都会丢),所以必须轮询。我的策略是:创建后立即查一次 → 3 秒后查 → 10 秒后查 → 30 秒后查 → 1 分钟后查 → 后续每 5 分钟查一次,最多查 24 小时。
# 轮询函数(带指数退避) import time import random def poll_batch_status(batch_id: str, max_retries: int = 288): # 24小时 * 5分钟/次 for i in range(max_retries): try: resp = requests.get( f"https://api.mch.weixin.qq.com/v3/pay/transfer/batches/{batch_id}", headers=get_v3_auth_headers() # 包含 Authorization 头 ) if resp.status_code == 200: data = resp.json() status = data.get("batch_status") if status == "FINISHED": return data elif status in ["PROCESSING", "PENDING"]: # 指数退避:第1次3秒,第2次10秒,第3次30秒,之后固定60秒 wait_time = [3, 10, 30] + [60] * (max_retries - 3) time.sleep(wait_time[min(i, len(wait_time)-1)]) continue else: raise Exception(f"Batch failed: {status}") except Exception as e: print(f"Poll failed: {e}") time.sleep(60) raise TimeoutError("Batch polling timeout")5.3 失败自动重试:对NETWORK_ERROR、SYSTEMERROR等临时错误,必须实现带退避的重试,但对BALANCE_NOT_ENOUGH等业务错误绝不重试
微信接口错误分两类:可重试(网络超时、系统繁忙)和不可重试(余额不足、用户 openid 错误)。源码包的重试逻辑必须精准区分:
| 错误码 | 类型 | 是否重试 | 最大重试次数 | 退避策略 |
|---|---|---|---|---|
NETWORK_ERROR | 临时 | ✅ | 3 | 指数退避(1s, 2s, 4s) |
SYSTEMERROR | 临时 | ✅ | 3 | 指数退避 |
BALANCE_NOT_ENOUGH | 业务 | ❌ | 0 | 记录告警,人工介入 |
INVALID_REQUEST | 业务 | ❌ | 0 | 检查参数,修复代码 |
INVALID_SIGNATURE | 配置 | ❌ | 0 | 检查证书、密钥、时间 |
# 重试装饰器(仅针对临时错误) import functools import time def wechat_retry(max_tries=3, backoff_factor=1): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for i in range(max_tries): try: return func(*args, **kwargs) except WechatTemporaryError as e: if i == max_tries - 1: raise wait = backoff_factor * (2 ** i) + random.uniform(0, 1) time.sleep(wait) return None return wrapper return decorator # 在调用 v2/v3 接口的函数上加装饰器 @wechat_retry(max_tries=3, backoff_factor=1) def transfer_to_wallet_v2(params): # v2 打款逻辑 pass我的习惯:所有打款请求必须记录完整日志(请求参数、响应体、耗时、错误码),日志级别设为
INFO,并接入 ELK 做关键词告警(如err_code:.*、result_code:FAIL)。曾经靠这条规则,在凌晨 3 点发现某台服务器 NTP 同步失效,timestamp偏差 302 秒,导致 v3 批量失败——提前 4 小时止损。希望帮到你。
本文还有配套的精品资源,点击获取