对接微信公众号支付时,签名环节是绕不开的一道坎。统一下单接口返回签名错误、前端调起支付时提示paySign校验失败、同一套商户密钥在Java和PHP里算出的结果对不上,这些问题的表现形式五花八门,根源却大多落在同一处:待签名串的拼接细节没处理好。这篇文章把JSAPI支付V2版本的签名规则完整拆解一遍,再分别给出Java、PHP、Python三种语言的可复用工具类,收进项目就能直接跑。

JSAPI签名的核心规则:待签名串到底长什么样
微信支付V2版本的签名,本质上是对一个特定格式的字符串做摘要运算。生成待签名串分五步:先把所有请求参数里值为空的字段和sign字段本身剔除掉;然后把剩下的参数按照参数名的ASCII码从小到大排序;接着按排序后的顺序拼成key1=value1&key2=value2的形式;在字符串末尾再拼上key=商户API密钥;最后对整个字符串做MD5或HMAC-SHA256运算,并把得到的十六进制结果转成大写,这就是最终的sign值。
规则看起来简单,但每一步都埋着坑。排序必须严格按ASCII码值比较,而不是按自然语言习惯排序,PHP的ksort如果不显式传SORT_STRING,遇到纯数字的参数名就可能按数值比较,顺序直接错掉。空值过滤只过滤空字符串和null,值为0的字段要保留。拼接时用的是参数原始值,不做URL编码,这一点和不少开放平台的签名规则不同,很多开发者在这里栽过跟头。另外整个签名过程必须统一使用UTF-8编码,密钥末尾有没有多余的空格也要检查,一个看不见的空格就能让签名永远对不上。
还有一个容易混淆的点:统一下单接口的sign和前端调起支付的paySign是两回事。统一下单签的是请求参数全集,而paySign只签五个字段:appId、timeStamp、nonceStr、package、signType。拿到预支付交易会话标识prepay_id之后,需要用这五个字段重新生成一次签名传给前端,直接把统一下单的sign塞给前端,是新手最常犯的错误之一。
Java版签名工具类:用TreeMap锁定参数顺序
Java实现的关键在于参数排序。推荐直接用TreeMap接收参数,它的天然排序规则就是按key的字符串ASCII码升序,省去手动排序的麻烦。如果调用方传的是普通HashMap,工具类内部再包一层TreeMap做兜底,两种情况都能覆盖。下面这份工具类同时支持MD5和HMAC-SHA256两种签名类型,与微信支付的signType参数一一对应,没有第三方依赖。
import java.security.MessageDigest;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class WxPaySignUtil {
/**
* MD5签名,适用于signType为MD5的场景
*/
public static String signWithMd5(SortedMap<String, String> params, String apiKey) throws Exception {
String signStr = buildSignString(params, apiKey);
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(signStr.getBytes("UTF-8"));
return toHex(digest).toUpperCase();
}
/**
* HMAC-SHA256签名,适用于signType为HMAC-SHA256的场景
*/
public static String signWithHmacSha256(SortedMap<String, String> params, String apiKey) throws Exception {
String signStr = buildSignString(params, apiKey);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(apiKey.getBytes("UTF-8"), "HmacSHA256"));
byte[] digest = mac.doFinal(signStr.getBytes("UTF-8"));
return toHex(digest).toUpperCase();
}
/**
* 拼接待签名串:过滤sign与空值,按key的ASCII码排序,末尾拼接key
*/
private static String buildSignString(SortedMap<String, String> params, String apiKey) {
TreeMap<String, String> sorted = new TreeMap<String, String>(params);
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sorted.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if ("sign".equals(key) || value == null || value.length() == 0) {
continue;
}
sb.append(key).append("=").append(value).append("&");
}
sb.append("key=").append(apiKey);
return sb.toString();
}
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(b & 0xFF);
if (hex.length() == 1) {
sb.append('0');
}
sb.append(hex);
}
return sb.toString();
}
}
这份代码有几个细节值得说明。getBytes("UTF-8")必须显式指定字符集,依赖平台默认编码在Windows服务器上可能按GBK处理,中文商品描述一进签名串就出错。toHex方法里b & 0xFF先把byte提升为int并截断高位符号位,否则负数byte转出来的十六进制会多出一串f前缀。HMAC-SHA256的密钥就是商户API密钥,初始化SecretKeySpec时同样要转成UTF-8字节数组。
实际调用时,把统一下单需要的所有业务参数放进TreeMap,调用signWithMd5或signWithHmacSha256得到sign,再把sign塞回参数集合一起组装成XML发给统一下单接口即可。如果项目里已经引入了SDK方式对接,注意SDK内部可能已经做了签名,外面再包一层手动签名同样会报错。
PHP版签名工具类:ksort的排序陷阱必须绕开
PHP的关联数组保留插入顺序,所以签名前必须显式排序。这里最大的坑是ksort的第二个参数:不传的话默认走SORT_REGULAR,会根据元素内容智能判断比较方式,参数名里出现纯数字字符串时可能被当成数值参与排序,结果和微信要求的ASCII码序不一致,验签必然失败。稳妥的做法是固定传SORT_STRING,强制按字符串逐字符比较。
<?php
class WxPaySignUtil
{
// MD5签名
public static function signWithMd5(array $params, $apiKey)
{
$signStr = self::buildSignString($params, $apiKey);
return strtoupper(md5($signStr));
}
// HMAC-SHA256签名
public static function signWithHmacSha256(array $params, $apiKey)
{
$signStr = self::buildSignString($params, $apiKey);
return strtoupper(hash_hmac('sha256', $signStr, $apiKey));
}
// 拼接待签名串
private static function buildSignString(array $params, $apiKey)
{
$filtered = array();
foreach ($params as $key => $value) {
if ($key === 'sign' || $value === '' || $value === null) {
continue;
}
$filtered[$key] = $value;
}
// 关键:必须按字符串规则排序,避免数字键名被当作数值比较
ksort($filtered, SORT_STRING);
$pairs = array();
foreach ($filtered as $key => $value) {
$pairs[] = $key . '=' . $value;
}
$pairs[] = 'key=' . $apiKey;
return implode('&', $pairs);
}
}
这份实现把签名串的拼接抽成了私有方法,两种摘要算法共用同一份逻辑,避免维护两处代码。MD5直接用内置的md5函数,HMAC-SHA256用hash_hmac,注意hash_hmac的参数顺序是先数据后密钥,写反了不会报错但结果完全不对,这是PHP对接微信支付时的高频事故点。拼接分隔符用的是&,和微信文档里的待签名串格式保持一致,不要顺手改用http_build_query的输出,后者会对值做URL编码,签名结果直接作废。
另外提醒一点,老项目从PHP5升级到PHP7之后,个别边界场景下数组函数的行为有细微差异,升级时最好用同一组参数在两个环境各跑一遍签名比对,确认结果一致再切换流量。
Python版签名工具类:hmac模块的正确打开方式
Python从3.7开始字典保持插入顺序,但签名依然不能依赖这个特性,必须用sorted显式排序。排序的key直接取参数名本身,Python字符串比较默认按Unicode码点进行,对ASCII字符来说和微信要求的ASCII码序完全一致,不需要额外处理。摘要运算前记得把字符串encode成bytes,Python3的hashlib不接受str直接入参,漏了这步会直接抛类型错误。
import hashlib
import hmac
def build_sign_string(params, api_key):
"""拼接待签名串:过滤sign与空值,按key的ASCII码排序"""
filtered = {
k: v for k, v in params.items()
if k != 'sign' and v is not None and v != ''
}
sorted_items = sorted(filtered.items(), key=lambda item: item[0])
pairs = ['{}={}'.format(k, v) for k, v in sorted_items]
pairs.append('key={}'.format(api_key))
return '&'.join(pairs)
def sign_with_md5(params, api_key):
sign_str = build_sign_string(params, api_key)
return hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()
def sign_with_hmac_sha256(params, api_key):
sign_str = build_sign_string(params, api_key)
digest = hmac.new(
api_key.encode('utf-8'),
sign_str.encode('utf-8'),
digestmod=hashlib.sha256
).hexdigest()
return digest.upper()
HMAC-SHA256用标准库的hmac模块,hmac.new的三个参数分别是密钥bytes、数据bytes和摘要算法,密钥同样是商户API密钥的UTF-8编码。整个函数式写法没有共享状态,多线程环境下可以放心复用。如果项目基于Django或Flask,把这两个函数放进utils模块即可,不必引入任何第三方依赖。
有个细节需要注意:如果参数值里混入了bytes类型,format拼出来的字符串会带上b开头的前缀,签名自然不对。从数据库或缓存取出的值,入库前统一转成str最省心。
paySign生成与V3版本RSA签名的差异
拿到prepay_id后,后端要为前端生成paySign。V2版本沿用同一套拼接规则,只是参与签名的字段固定为appId、timeStamp、nonceStr、package、signType这五个。paySign由后端算好下发给前端,前端不做任何签名运算,调起支付的代码大致如下。
wx.chooseWXPay({
timestamp: '1414723227',
nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS',
package: 'prepay_id=wx24232323',
signType: 'RSA',
paySign: '后端计算好的paySign',
success: function (res) {
// 支付成功回调
},
fail: function (res) {
// 支付失败或用户取消
}
});
如果项目已经升级到V3接口,签名体系就完全换了套路:不再用商户API密钥做对称摘要,而是用商户私钥做RSA非对称签名,算法固定为SHA256withRSA,签名结果是Base64编码而不是十六进制大写。V3的paySign待签名串也有讲究,四个字段之间用换行符\n分隔,而且最后一行末尾同样要带一个\n,少了这个尾部换行符,签名校验必挂。下面是Java版的V3 paySign生成方法,私钥从商户平台下载的apiclient_key.pem文件中加载。
import java.security.PrivateKey;
import java.security.Signature;
import java.util.Base64;
public class WxPayV3PaySign {
/**
* 生成JSAPI调起支付所需的paySign,使用商户私钥做RSA签名
* 注意:字段之间用\n分隔,且最后一行末尾同样要带\n
*/
public static String buildPaySign(String appId, String timeStamp,
String nonceStr, String packageValue, PrivateKey privateKey) throws Exception {
String message = appId + "\n"
+ timeStamp + "\n"
+ nonceStr + "\n"
+ packageValue + "\n";
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(privateKey);
signer.update(message.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(signer.sign());
}
}
V3接口的请求签名更复杂一些,待签名串由HTTP请求方法、请求URL、时间戳、随机串、请求体五部分用\n拼接而成,具体规则以官方文档为准。好在V3各语言都有官方或社区维护的SDK,签名这层基本被封装掉了,需要手写签名的场景集中在paySign这一处。
跨语言排错:三步定位签名不一致
多语言团队最头疼的问题是同一个参数集在不同语言里算出的签名不一样。排查方法很直接:第一步,把各语言的待签名串打印出来逐字符比对,九成的问题在这一步就能现形,重点看参数顺序、空值字段、末尾key拼接和看不见的空白字符;第二步,用微信支付官方提供的网页版签名验证工具,把待签名串和密钥贴进去看官方算出的结果,确认是待签名串拼错还是摘要运算本身有错;第三步,检查密钥来源,API密钥是商户平台API安全设置里那个32位字符串,和商户号、公众号AppSecret都不是一回事,用错密钥是报签名错误的头号原因。
再补充几个实战中反复出现的坑。时间戳要用秒级而不是毫级;nonce_str每次请求都要重新生成,复用会触发风控;金额单位是分,传成元会直接报参数错误;XML方式下单时字段名大小写敏感,appid写成appId在某些接口版本里会被判为无效参数。把这些点列成上线前的检查清单,能省掉大量来回调试的时间。
| 报错现象 | 高频原因 |
|---|---|
| 统一下单返回签名错误 | API密钥错误、待签名串拼接顺序不对、编码非UTF-8 |
| 前端调起支付报失败 | paySign签错字段、package格式不对、时间戳用了毫秒 |
| Java与PHP签名结果不同 | PHP排序标志缺失、空值过滤规则不一致 |
| V3接口验签失败 | 待签名串末尾缺少换行符、私钥文件加载错误 |
签名问题看着琐碎,一旦把待签名串打印出来对着规则逐项核对,定位起来其实很快。把上面三种语言的工具类收进项目公共库,后续新增支付场景时直接复用,多语言团队之间也能保证签名行为完全一致。