扫码支付成功后 notify_url 无法收到回调通知
结论
浏览器能打开 notify_url,不等于支付平台能正常向它发送异步通知。
排查时主要确认四件事:回调接口是否支持平台使用的请求方法和数据格式,公网服务器能否直接访问,接口能否在规定时间内返回指定的成功响应,以及订单号 9107fe7c018242078409c7618462910c 对应的交易是否确实支付成功。
目前没有明确具体的支付平台,因此无法直接判断通知参数、签名算法和成功响应格式。这些内容应以实际接入平台的接口文档为准。
常见原因
请求方法或数据格式不匹配
浏览器访问通常发出 GET 请求,而支付通知大多使用 POST。请求体可能采用以下格式:
application/x-www-form-urlencodedapplication/jsontext/xmlapplication/xml
如果接口只支持 GET、只解析 JSON,或者请求在到达业务代码前就被框架的参数校验拦截,就会出现”浏览器测试正常,实际通知收不到”的情况。
接口返回内容不符合要求
支付平台通常要求回调接口返回固定内容,例如:
success
或者:
<xml>
<return_code><![CDATA[SUCCESS]]></return_code>
<return_msg><![CDATA[OK]]></return_msg>
</xml>
有些平台则要求返回特定的 JSON。即使业务已经处理完成,只要 HTTP 状态码或响应正文不符合协议,平台仍可能把这次通知判定为失败并继续重试。
这些只是常见形式,不能混用。接口必须按照实际支付渠道的协议返回结果。
HTTPS、网络或安全策略拦截
需要逐项检查:
- 域名能否正常解析;
- HTTPS 证书是否有效,证书链是否完整;
- 请求是否经过 HTTP 301、302 或 307 跳转;
- 防火墙、安全组、WAF 或 CDN 是否拦截了平台请求;
- 是否设置了 IP 白名单;
- 是否限制了
User-Agent、请求头或请求体大小; - 是否启用了登录认证、Token 校验或验证码;
- 请求是否被 CSRF 防护拦截。
回调地址必须允许支付平台直接访问,不能依赖 Cookie、登录状态或前端页面跳转。
回调处理超时或发生异常
如果通知接口同步执行大量业务逻辑,例如查询数据库、调用第三方服务或发送短信,支付平台可能在处理完成前就等待超时。
接口内部发生异常时,也可能返回 500、框架错误页面或网关错误。浏览器手工访问使用的请求方式和数据不同,因此未必能复现这些问题。
支付请求中的 notify_url 未实际生效
先确认创建订单时提交给支付平台的参数中确实包含:
https://elderly-api.house-keeper.cn/api/v1/pay/notify1.json
同时排查:
- 参数名称或层级是否填写错误;
- 参数是否未参与签名,或者签名是否失败;
- SDK 是否使用了另一个默认回调地址;
- 商户后台配置是否覆盖了请求参数;
- 测试环境和生产环境的配置是否混用;
- 支付订单是否在修改配置前就已创建。
create_time 是 2021-12-31 13:56:33。如果排查的是这笔历史交易,还要确认支付平台是否仍保留当时的通知日志,以及是否支持重新发送该订单的通知。
建议的排查步骤
1. 查询支付平台侧的订单状态
使用支付平台的订单查询接口,按以下商户订单号查询:
9107fe7c018242078409c7618462910c
需要确认:
- 平台是否已受理该订单;
- 订单是否真正支付成功;
- 商户号、应用 ID 和支付环境是否正确;
- 平台记录的
notify_url是什么; - 是否有通知发送记录;
- 最近一次通知的 HTTP 状态码和响应内容是什么。
如果查询结果不是支付成功,平台自然不会发送成功通知。
2. 查看入口层访问日志
不要只看应用日志,还要检查 Nginx、网关、负载均衡、CDN 或 WAF 的日志。
以 Nginx 为例:
grep 'notify1.json' /var/log/nginx/access.log
grep 'notify1.json' /var/log/nginx/error.log
可以根据日志结果判断问题大致发生在哪一层:
- 没有任何请求记录:检查平台配置、DNS、证书、防火墙和订单状态;
- 出现
301或302:取消跳转,让回调地址直接处理请求; - 出现
401或403:检查认证、CSRF、WAF 和访问控制; - 出现
404:检查路由、上下文路径和反向代理配置; - 出现
405:接口不支持平台使用的 HTTP 方法; - 出现
415:接口不支持平台发送的Content-Type; - 出现
500或502:查看应用异常和上游服务状态; - 返回
200,但平台仍判定失败:核对响应正文和接口处理耗时。
3. 记录原始请求
排查期间应记录:
- 请求时间;
- HTTP 方法;
Content-Type;- 请求头;
- 原始请求体;
- 请求来源 IP;
- HTTP 状态码;
- 返回内容;
- 接口处理耗时。
写日志时要隐藏密钥、银行卡号、证件号等敏感信息。解析参数前不要丢弃原始请求体,否则验签失败时很难判断问题出在参数解析还是报文传输。
4. 按支付协议验签
收到通知后,必须使用支付平台规定的算法验证签名。不能只凭 out_trade_no 或”支付成功”字段更新订单状态。
验签时通常要注意:
- 平台要求使用原始请求数据,还是解析后的参数集合;
- 参数排序规则;
- 是否需要排除
sign字段; - 字符编码是否为 UTF-8;
- URL 解码次数是否正确;
- 公钥、证书或密钥是否属于当前商户和当前环境;
- 处理过程中是否错误修改了空格、换行或特殊字符。
验签失败时,可以记录失败原因和平台交易号,但不能输出私钥或完整密钥。
5. 快速返回成功响应
推荐按以下顺序处理:
- 接收原始报文;
- 验证签名;
- 校验商户号、订单号、金额和币种;
- 通过数据库事务幂等更新订单;
- 返回支付平台规定的成功响应;
- 将耗时的后续业务放入队列异步处理。
在返回成功响应前,不要发送短信、生成报表或调用不稳定的第三方接口。
Spring Boot 接收示例
如果支付平台发送 JSON,可以先用原始字符串接收:
@RestController
@RequestMapping("/api/v1/pay")
public class PayNotifyController {
private static final Logger log =
LoggerFactory.getLogger(PayNotifyController.class);
@PostMapping(
value = "/notify1.json",
consumes = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<String> notify(
@RequestBody String rawBody,
HttpServletRequest request) {
log.info("pay notify received, contentType={}, body={}",
request.getContentType(), maskSensitiveData(rawBody));
// 1. 按支付平台文档验证签名
// boolean valid = signatureService.verify(rawBody, request);
// if (!valid) {
// return ResponseEntity.badRequest().body("fail");
// }
// 2. 解析通知并校验订单号、金额、币种、商户号和交易状态
// PayNotify notify = parse(rawBody);
// 3. 幂等更新订单
// orderService.markPaidIfNecessary(notify);
// 4. 返回值必须替换成支付平台规定的成功响应
return ResponseEntity.ok("success");
}
private String maskSensitiveData(String value) {
return value;
}
}
如果平台使用表单格式,应采用对应的接收方式:
@PostMapping(
value = "/notify1.json",
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE
)
public ResponseEntity<String> notify(
@RequestParam Map<String, String> params) {
// 验签、订单校验和幂等处理
return ResponseEntity.ok("success");
}
如果还不确定实际的 Content-Type,应先查看平台文档或入口日志。不要在没有确认协议的情况下,盲目声明多个格式后直接上线。
Nginx 配置检查
确认回调路径已正确转发,并且没有被重写到登录页:
location = /api/v1/pay/notify1.json {
proxy_pass http://payment_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
}
还要仔细检查 proxy_pass 的路径拼接规则。配置错误可能导致后端实际收到的 URI 与回调地址不同。
本地模拟请求
确认平台的报文格式后,可以用 curl 测试 POST 请求,不要只在浏览器中打开回调地址。
JSON 示例:
curl -i -X POST \
'https://elderly-api.house-keeper.cn/api/v1/pay/notify1.json' \
-H 'Content-Type: application/json' \
--data '{"out_trade_no":"9107fe7c018242078409c7618462910c","trade_status":"SUCCESS"}'
表单示例:
curl -i -X POST \
'https://elderly-api.house-keeper.cn/api/v1/pay/notify1.json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'out_trade_no=9107fe7c018242078409c7618462910c' \
--data-urlencode 'trade_status=SUCCESS'
这些命令只能测试网络、路由和参数接收能力,不能证明真实支付通知的签名及业务处理没有问题。模拟数据也不能直接用于把生产订单更新为已支付。
必须做好幂等处理
支付平台可能因为等待超时或没有收到正确响应而重复发送通知。回调接口不能假设每条通知只会到达一次。
可以使用条件更新保证幂等:
UPDATE payment_order
SET pay_status = 'PAID',
paid_at = CURRENT_TIMESTAMP
WHERE out_trade_no = '9107fe7c018242078409c7618462910c'
AND pay_status = 'UNPAID';
如果受影响行数为 0,需要查询订单当前状态。订单已经是 PAID 时,可以按照平台协议再次返回成功,避免平台继续进行无意义的重试。
注意事项
- 回调来自公网不代表可以跳过验签。
- 除订单号外,还要核对金额、币种、商户号和应用 ID。
- 不要把回调作为确认支付结果的唯一方式。订单长时间没有更新时,可以通过订单查询接口主动补偿。
- 回调地址不能使用临时域名、过期证书或需要登录的路径。
- 不要将支付回调永久重定向到其他地址。
- 不要手工伪造回调后直接更新生产订单。
- 修改回调配置通常只影响新创建的订单。历史订单是否采用新配置,取决于支付平台的规则。
最快的排查方法是先查看支付平台的通知记录,再对照 Nginx 访问日志。如果平台显示通知已经发出,而入口层完全没有记录,应优先检查网络和安全策略。如果入口层有记录,则结合 HTTP 状态码、响应正文和应用日志继续定位。
备注:内容仅供参考。
