支付宝要 7 个字符 success,微信要空响应体:支付异步通知的应答语义与重试时间表
支付回调的坑基本不在业务代码,而在应答:同一套 JSON 响应封装发给支付宝、微信,三家全判失败。另一个坑是应答早于持久化——回了成功,业务处理挂了,重发窗口已经关掉。下面按渠道整理官方文档里的应答要求和重试时间表,并给出 Go / PHP 的 handler 骨架。
支付宝:body 必须只有 success 七个字符
下单时传入的 notify_url 会收到支付宝以 POST 方式发来的支付结果参数。商户处理完通知后,必须向响应体输出 success——7 个字符,不含引号,不能有其他内容(空格、换行、HTML、框架 trace 都算其他内容)。
不是 success,支付宝服务器会不断重发,直到超过 24 小时 22 分钟。一般情况下 25 小时以内完成 8 次通知,间隔为 4m、10m、10m、1h、2h、6h、15h。新版「异步通知说明」补充了一条:未收到 success 时立即重试 3 次,3 次仍不成功,再走上面的 4m、10m、10m、1h、2h、6h、15h。
应答值语义:
| 应答 | 含义 | 是否重试 |
|---|---|---|
fail | 消息获取失败 | 重试 |
success | 消息获取成功 | 不重试 |
建议验签成功后输出 success,验签失败返回 fail 或其他内容,等下一次重发。
文档存在一处不一致:另一个 FAQ 说「异步回调地址状态码为 200 表示异步通知成功,404 或 500 表示服务器内部错误」。这与「必须输出 success 字符」的说明并存。实际以响应体是否为 success 为准:HTTP 200 但 body 为空、多了换行、被框架包了一层 JSON,都会被判失败,然后严格按 4m/10m/10m/1h/2h/6h/15h 的节奏重发。
notify_url 本身也有约束:页面上不能有任何多余字符,如空格、HTML 标签、框架抛出的异常提示;不能执行页面跳转(跳转会导致收不到 success 而被判为异常重发);POST 方式接收,取参用 request.Form("out_trade_no") / $_POST['out_trade_no'];cookie/session 在这个页面失效;必须外网可访问。
去重维度上,同一条异步通知重试时参数中的 notify_id 不变,打印 success 后 notify_id 才失效。但交易状态改变会触发不同 trade_status 的异步通知,不同状态的通知必须各自应答 success,否则同一状态会重复发送。返回 success 后带其他信息,同样会导致同一个状态的交易通知重复发送。
微信支付 APIv3:200/204 空报文,5 秒内应答
收到支付结果通知,需在 5 秒内返回应答报文,否则微信认为通知失败并重复发送。
接收成功返回 HTTP 200 或 204,无需应答报文;接收失败返回 HTTP 4XX/5XX,同时返回 JSON 报文 {code: string[1,32], message: string[1,256]},code 为 FAIL 表示接收失败。
验签通过 → 应答 200/204;验签不通过 → 应答 4XX/5XX,等待微信携带正确签名重新发送。官方建议先应答再处理业务逻辑(如更新订单状态),推荐异步处理,避免业务耗时过长导致应答超时。
通知频率(部分产品文档):15/15/30/180/1800/1800/1800/1800/3600,单位秒。另一处文档(商家转账等)为 0s / 15s(尝试 3 次)/ 300s(尝试 3 次)/ 1800s(尝试 3 次)/ 1h(尝试 22 次)。
所有通知频率发完仍未收到回调,商户应主动调用查询接口确认订单状态,不能只依赖回调。
重复通知的处理:先检查业务数据状态,未处理则处理,已处理直接返回成功;状态检查与处理前要用数据锁做并发控制,避免函数重入。
探测流量也要留意:微信会在极少数应答或通知回调中生成错误签名,用来探测商户是否验签,签名值前缀为 WECHATPAY/SIGNTEST/。
notify_url 必须直接可访问、不能携带参数,公网域名必须是 https。
微信支付 V2:XML 里 return_code=SUCCESS
应答 XML:
return_code 是通信标识,不是交易标识,交易是否成功要看 result_code。
重试频率:15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h,总计 24h4m。通知可能由多台服务器发送,几秒内重复;微信不保证最终成功。未收到通知或状态不明时,主动查单。
ACK 之前必须落库
三家应答格式互不兼容,通用 JSON 响应封装会全部判失败:
| 渠道 | 成功应答 |
|---|---|
| 支付宝 | 响应体纯文本 success(7 字符,无引号、无额外内容) |
| 微信 V3 | HTTP 200 / 204,空响应体 |
| 微信 V2 | XML return_code=SUCCESS |
ACK 之前必须完成可恢复的持久化:入站通知表 + 唯一键。返回成功后再处理业务失败,就再没有重发机会。重试窗口长度差一个量级——支付宝只有 8 次 / 25 小时,微信 V3 有 1 小时一档的 22 次重试。
唯一键的选取要避开只按 notify_id 去重的陷阱:notify_id 在重试时不变,可以作去重键,但同一笔交易不同状态的通知 notify_id 不同,只按它判「已处理」会漏掉状态迁移。用 out_trade_no + trade_status 做业务唯一键更稳。
5 秒预算的分配:验签 + 落库 + 入队,业务处理全部异步。
Go:支付宝 handler
func alipayNotify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1prepare(
'INSERT IGNORE INTO pay_inbound(channel, notify_id, uk, payload, created_at)
VALUES (?,?,?,?,NOW())'
);
$st->execute(['alipay', $post['notify_id'] ?? '', $uk, $raw]);
enqueue('alipay', $post['notify_id']);
header('Content-Type: text/plain; charset=utf-8');
echo 'success';
exit;
微信 V3 的对应实现只有一处差异:验签通过后直接 w.WriteHeader(http.StatusNoContent) 或返回 200 空 body,失败时返回 4xx/5xx 和 {"code":"FAIL","message":"..."}。
从重试节奏反推故障
看到反复的 4m/10m/10m/1h/2h/6h/15h,是支付宝没识别到 success——body 多了空格或换行、被模板包了 HTML、框架输出 trace。看到 15/15/30/180 的短间隔密集重试,是微信侧在重发。两套节奏可以直接作为线上排障的第一判断依据。