自己拉微信/支付宝账单做对账:签名、GBK、元转分这几处最容易“假通过”
支付回调不能当作对账依据。网络抖动、服务重启、重试队列丢失都会让回调缺失;同一个回调重复投递时,又需要靠本地幂等去重;退款回调、服务费、分润、券这些条目,与支付回调根本不在同一条消息流里。平台账单是唯一与资金实际变动一致的数据来源。
微信侧的官方约定说得很直接:对账须先申请账单、再下载并核验 hash_value;交易账单须与本地订单逐笔核对,微信侧成功而本地未成功的必须有处理动作。只下载不核对,等于没做。
下面按微信 v3、支付宝、落库比对、坑清单走一遍。
微信支付 APIv3 交易账单
申请账单
GET https://api.mch.weixin.qq.com/v3/bill/tradebill?bill_date=2019-06-11&bill_type=ALL&tar_type=GZIP
Authorization: WECHATPAY2-SHA256-RSA2048 ...
Accept: application/json
参数约束:
| 参数 | 说明 |
|---|---|
bill_date | 必填,yyyy-MM-DD。仅支持三个月内,不能是当天(当日不可下载,次日 10 点后生成;微信次日 9 点开始生成) |
bill_type | ALL(当日所有订单,不含充值退款)/ SUCCESS(成功支付)/ REFUND(退款单) |
tar_type | GZIP 或不传(返回字符串流) |
应答里三个字段:
hash_type:固定SHA1hash_value:账单文件的 SHA1 摘要download_url:5 分钟内有效
download_url 拿到手还要再签一次
这是第一个容易踩的点:download_url 不能直接丢进浏览器或 wget。下载请求同样按 v3 规则重新生成签名,GET,path + query 参与签名。
GET
/v3/billdownload/file?token=xxx&t=xxx
1554208460
593BEC0C930BF1AFEB40B4A08C8FB242
(空行)
签名串拼好后用商户私钥 SHA256-RSA 签名,塞进 Authorization。
curl -sS "$DOWNLOAD_URL" \
-H "Authorization: WECHATPAY2-SHA256-RSA2048 mchid=\"$MCHID\",nonce_str=\"$NONCE\",signature=\"$SIG\",timestamp=\"$TS\",serial_no=\"$SERIAL\"" \
-o bill.gzip
下载侧几个细节:
- 响应头不含签名,因此下载响应无需验签;
- 账单内容默认按
utf-8读取; tar_type=GZIP时返回.gzip流,先解压再解析;- 下载完自己算一遍 SHA1,与申请接口返回的
hash_value比对。
raw, _ := io.ReadAll(resp.Body) // 已是解压后的字节时跳过 gzip 步骤
sum := sha1.Sum(raw)
if hex.EncodeToString(sum[:]) != hashValue {
return fmt.Errorf("bill hash mismatch")
}
如果响应是 gzip 流:
gz, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil { return err }
plain, err := io.ReadAll(gz)
错误码
400 INVALID_REQUEST400 NO_STATEMENT_EXIST:账单文件不存在,或该日无交易、无退款400 STATEMENT_CREATING:账单生成中,T+1 上午 10 点后再试429 FREQUENCY_LIMITED
其余限制
- 账单内只有支付成功的订单;
- 账单金额单位是「元」,不是分;
- 仅可下载三个月内账单。
支付宝账单下载
接口:alipay.data.dataservice.bill.downloadurl.query。
bill_type 取值:
| 值 | 含义 |
|---|---|
trade | 交易业务账单,对应商家平台-交易账单 |
signcustomer | 资金账单,账户收支 |
merchant_act | — |
trade_zft_merchant | — |
zft_acc | — |
settlementMerge | 结算到卡批次明细 + 汇总 |
bill_date:
- 日账单
yyyy-MM-dd,T+1,当日数据次日 9 点前生成,不支持当日; - 月账单
yyyy-MM,次月 3 号左右生成; - 接口仅支持近 6 年。
响应里 bill_download_url 30 秒内未下载即失效;bill_file_code=EMPTY_DATA_WITH_BILL_FILE 表示该周期无数据的空账单。
zip 内是两个 csv,编码不是 UTF-8
下载得到 zip 流,解压后含两个文件:xxx_业务明细.csv 与 xxx_业务明细(汇总).csv。
默认 GBK 编码(有资料写 GB2312)。注意两处编码都要指定:
- csv 内容本身;
- zip 内文件名含中文,zip 流的编码也得指定——Java 是
new ZipInputStream(fis, Charset.forName("gbk")),.NET 是Encoding.GetEncoding("GB2312")并先注册CodePagesEncodingProvider。
PHP 里逐个文件转:
$zip = new ZipArchive();
$zip->open($path);
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i); // 中文名注意编码
$fh = $zip->getStream($name);
while (($line = fgets($fh)) !== false) {
$line = mb_convert_encoding($line, 'UTF-8', 'GBK');
if ($line[0] === '#') continue; // 说明行与结尾行
// parse csv...
}
}
明细 csv 结构
前 4~5 行是 # 开头的说明:
#支付宝业务明细查询
#账号
#起始日期 终止日期
#----业务明细列表----
第 5 行是表头。末几行也是 # 开头:#业务明细列表结束 / #交易合计:N笔… / #退款合计 / #导出时间。解析时统一跳过 # 行,不要按固定行号切。
表头字段:
支付宝交易号,商户订单号,业务类型,商品名称,创建时间,完成时间,门店编号,门店名称,操作员,终端号,对方账户,订单金额(元),商家实收(元),支付宝红包(元),集分宝(元),支付宝优惠(元),商家优惠(元),券核销金额(元),券名称,商家红包消费金额(元),卡消费金额(元),退款批次号/请求号,服务费(元),分润(元),备注
业务类型为「交易」或「退款」;退款行金额为负数。退款批次号/请求号对应退款接口的 out_request_no——同一交易多次部分退款,每次必须用不同的 out_request_no。
字段对应关系:
| 账单列 | 系统字段 |
|---|---|
| 支付宝交易号 | trade_no |
| 商户订单号 | out_trade_no |
| 订单金额(元) | total_amount |
| 商家实收(元) | receipt_amount(= 订单金额 - 商家出资优惠) |
| 服务费(元) | charge_amount |
| 退款批次号/请求号 | batch_no |
解压失败
多数是下载不完整,重下即可。另一个原因是文件过大:接口与商家平台不支持 1G 以上文件,月账单过大时按日账单分批下。
落库比对
金额只转一次
接口侧用整数分,账单里是「元」的十进制字符串。进入系统时只做一次转换:字符串 → 分。用 decimal,或直接按小数点拆整数与小数部分,别用 float。
function yuanToFen(string $s): int {
[$i, $d] = array_pad(explode('.', $s, 2), 2, '');
$d = str_pad(substr($d, 0, 2), 2, '0');
return (int)$i * 100 + (int)$d;
}
元与分混用是对账差异里最常见的假阳性:账单没变,代码换了个单位,第二天报表多出一堆「金额不一致」。
差异分类
| 情况 | 处理 |
|---|---|
| 一致 | 跳过 |
| 平台有本地无 | 漏单,补单 + 告警 |
| 本地有平台无 | 可能未支付成功或已关单,需查单 |
| 金额不一致 | 高优告警,人工介入 |
退款单必须按 out_request_no 做唯一键,否则同一笔交易的部分退款多次会被折叠成一条。
下载与存储
download_url 过期(微信 5 分钟、支付宝 30 秒)就不要复用,重新申请。下载逻辑要幂等:同日重复下载覆盖同一存储路径,避免堆出一堆半成品文件。hash_value 一并落库,作为账单版本标记——重下时能立刻判断内容有没有变。
常见坑清单
- 微信账单当天不可下载,
bill_date传今天只会拿到错误;次日 9 点开始生成,10 点后再试。 STATEMENT_CREATING不是失败,是还没好,别当异常报警。download_url直接浏览器打开——少了 v3 签名,必然失败。- 忘了
path + query一起参与签名,签出来的东西服务端不认。 - 微信金额是元,接口金额是分,转换做两遍就永远对不上。
- 微信账单只含支付成功订单,
ALL也不含充值退款;本地未支付的单本来就不会出现在账单里,别当成漏单。 - 支付宝
bill_download_url只有 30 秒,串行下载多个账单时很容易超时,申请完立刻下。 - zip 内文件名是中文,只设了内容编码没设 zip 流编码,解压出来的文件名是乱码。
- csv 用 GBK/GB2312 读,按固定行号切表头,遇到末行
#注释就 panic。 - 退款行金额是负数,做汇总时符号处理错,退款越多账越"平"。
- 部分退款不按
out_request_no建唯一键,多次退款被折叠成一条。 - 不校验
hash_value,截断的文件照样入库,最后表现为零星的金额差异。