微信支付的退款查询接口中,refund_info 字段里的 refund_recv_accout 等敏感信息是加密传输的,官方使用 AES-256-ECB 算法加密,密钥为商户 API 密钥的 md5 值(小写)。开发者拿到密文后需要自行解密,再从解密结果的 XML 中提取所需字段。整个流程看似清晰,但有一个非常隐蔽的坑:解密出来的字符串开头往往带有 BOM 头(字节 EF BB BF),导致 XML 解析器直接报错,比如 PHP 的 simplexml_load_string 返回 false,Java 的 DocumentBuilder 抛出 Content is not allowed in prolog 异常。因为 BOM 头是不可见字符,肉眼完全看不出来,很多开发者排查很久都定位不到问题。

一、微信退款结果解密的完整流程
在解决问题之前,先把整个解密流程梳理清楚。微信支付退款查询接口返回的 JSON 或 XML 数据中,退款详情字段 req_info 是一段 Base64 编码的密文。解密分为三步:第一步,对返回的 req_info 做 Base64 解码,得到加密的二进制数据;第二步,使用商户 API 密钥的 md5 小写值作为 AES 密钥,采用 AES-256-ECB 模式、PKCS7 填充方式进行解密;第三步,解密结果是一段 XML 字符串,包含 out_refund_no、refund_recv_accout、refund_success_time 等节点,解析后即可使用。
下面以 PHP 为例演示标准解密代码:
<?php
// $reqInfo 为接口返回的加密字符串
$key = md5($apiKey); // API密钥的md5小写值,32位
$cipherText = base64_decode($reqInfo);
$xmlStr = openssl_decrypt(
$cipherText,
'AES-256-ECB',
$key,
OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING
);
// 注意:这里拿到的 $xmlStr 可能带有BOM头,直接解析会失败
$xml = simplexml_load_string($xmlStr);
if ($xml === false) {
// 大概率就是BOM头导致的
echo "XML解析失败";
}
这段代码在大多数情况下能正常工作,但一旦解密结果开头混入了 BOM 头,simplexml_load_string 就会静默失败。理解了这个流程,就能明白问题发生在第三步:解密本身是成功的,只是解密产物不干净。
二、BOM 头产生的原因以及为什么会导致 XML 解析失败
BOM 全称是 Byte Order Mark,即字节顺序标记,对应的字节序列是 EF BB BF。它通常出现在 UTF-8 编码的文本文件开头,用来告诉解析程序这是一个 UTF-8 文档。很多编辑器(比如 Windows 记事本旧版本)保存 UTF-8 文件时会自动添加 BOM 头。微信侧在生成退款信息 XML 时,如果原始模板文件带 BOM 头,加密前的明文中就会包含这三个字节,解密后自然也原样带出来。
为什么这三个字节会让解析器报错?根据 XML 规范,XML 声明 <?xml version="1.0"?> 必须出现在文档的最开头,前面不允许有任何其他字符。BOM 头虽然对人是不可见的,但对解析器来说是实实在在的三个字节,位于 XML 声明之前,等于破坏了文档的起始结构。于是不同语言的解析器会给出不同的报错:PHP 返回 false 不给提示,Java 报 Content is not allowed in prolog,Python 的 ElementTree 则提示 XML or text declaration not at start of entity。这些报错信息都不直接指向 BOM,所以很容易误导排查方向。
验证是否存在 BOM 头的方法很简单,直接查看解密结果的十六进制值即可:
<?php $hex = bin2hex(substr($xmlStr, 0, 10)); echo $hex; // 如果输出以 efbbbf 开头,说明存在BOM头 // 例如:efbbbf3c3f786d6c20 对应的就是 BOM + "<?xml "
如果输出的前六个字符是 efbbbf,就可以确认问题根源了。
三、各语言去除 BOM 头并正确解析的方案
去除 BOM 头的思路很统一:在解析前检测字符串前三个字节是否为 EF BB BF,是则截掉。下面给出几种常用语言的实现。
PHP 方案,推荐用 substr 检测并截取,也可以封装成通用函数:
<?php
function removeBom($str) {
if (substr($str, 0, 3) === pack("CCC", 0xEF, 0xBB, 0xBF)) {
$str = substr($str, 3);
}
return $str;
}
$xmlStr = removeBom($xmlStr);
$xml = simplexml_load_string($xmlStr);
$refundAccount = (string)$xml->refund_recv_accout;
Java 方案,先转成字符串再判断,或者在流层面处理:
String xmlStr = new String(decryptedBytes, StandardCharsets.UTF_8);
if (xmlStr.startsWith("\uFEFF")) {
xmlStr = xmlStr.substring(1);
}
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
Document doc = factory.newDocumentBuilder()
.parse(new InputSource(new StringReader(xmlStr)));
Python 方案,用 lstrip 或者编码层面的处理都可以:
import xml.etree.ElementTree as ET
xml_str = decrypted.decode('utf-8-sig') # utf-8-sig 自动去掉BOM
root = ET.fromstring(xml_str)
refund_account = root.find('refund_recv_accout').text
其中 Python 的 utf-8-sig 编码是最优雅的解法,它在解码时会自动识别并剥离 BOM 头,无需手动判断。而 Java 中的 \uFEFF 是 BOM 对应的 Unicode 字符,通过判断开头是否为该字符来处理也很直观。
四、退款解密失败的完整排查思路
除了 BOM 头,退款解密失败还有其他常见原因,按出现频率整理如下,方便系统排查:
- 密钥错误:AES 密钥必须是商户 API 密钥的 md5 小写值,注意大小写,APIv3 密钥和旧版 API 密钥不能混用,退款 req_info 用的算法是旧版 API 密钥体系。
- Base64 解码遗漏:req_info 是 Base64 编码的,必须先 base64_decode 再解密,顺序颠倒必然失败。
- 填充方式不对:微信使用 PKCS7 填充,PHP 中应设置 OPENSSL_RAW_DATA,如果用错 padding 模式,解密结果末尾会出现乱码。
- 密文不完整:req_info 字段在传输或截取时被截断,解密会直接报错或产生乱码。
推荐的排查顺序是:先确认 Base64 解码成功,再确认 md5 密钥正确,然后检查解密结果的前几十个字节的十六进制值,最后再看 XML 解析环节。养成先 bin2hex 看一眼的习惯,能省去大量盲猜时间。
五、总结与防御性编码建议
微信退款结果解密失败中,BOM 头问题占比相当高,特征是解密不报错但 XML 解析报错,且报错信息都指向文档起始位置异常。解决方案就是在解析前统一做一次 BOM 剥离,这一步成本极低却能根治问题。
从防御性编程角度,建议把去除 BOM 的逻辑封装进通用的 XML 解析工具函数中,所有来自外部系统的 XML 一律先经过处理再解析。同时记录解密结果的原始十六进制日志,遇到线上问题时能快速回溯。另外,凡是涉及第三方接口的数据解析,都不要默认数据是完全规范的,多一层字节级校验,系统就多一分健壮性。