PAYATHON 2026

App 支付未触发支付回调,但回调接口可正常访问

支付小周

结论

App 显示“支付成功”,并不代表服务端已经收到支付平台的异步通知。回调接口能在浏览器或 curl 中访问,也只能说明部分网络链路正常,无法证明支付平台能按指定方式请求该接口,更无法证明后续的验签、解析和业务处理已经成功。

排查时通常要确认以下四点:

  1. 下单时是否向支付平台传入了正确的回调地址;
  2. 支付平台是否确实发出了通知;
  3. 通知是否被网关、WAF、HTTPS、重定向或应用层拦截;
  4. 接口是否因为验签失败、解析错误、程序异常或响应不符合要求,被误判为“没有回调”。

先区分两类“回调”

App 支付通常会产生两类结果通知:

  • 客户端支付结果:由支付 SDK 返回给 App,只能用于页面展示,不能作为最终入账依据。
  • 服务端异步通知:支付平台主动请求商户服务器,用于确认支付结果并更新订单状态。

用户关闭 App、网络中断或 SDK 没有正常返回,都不应影响服务端接收异步通知。同样,App 收到成功结果也不能代替服务端通知。

订单的最终状态应以支付平台的异步通知或主动查询结果为准。

常见原因

1. 下单时没有正确设置回调地址

不要只检查配置文件,还要核对创建支付订单时实际发送的参数:

  • notify_url、notifyUrl 等字段是否为空;
  • 参数名是否符合当前支付渠道的 API 定义;
  • 回调地址是否被环境变量覆盖;
  • 测试环境和生产环境是否使用了不同地址;
  • 是否误把前端跳转地址当成异步通知地址;
  • 是否仍在使用旧订单,导致修改配置后测试的还是原回调地址。

建议在下单日志中记录商户订单号、支付平台订单号和实际使用的回调地址。敏感信息必须脱敏,私钥、密钥和完整授权信息不能写入日志。

2. 回调地址“能访问”,但支付平台访问不到

本机或浏览器访问成功,不代表支付平台也能访问。两者之间可能存在以下差异:

  • 地址是 localhost、内网 IP,或者只能通过 VPN 访问;
  • DNS 在部分地区或网络中解析异常;
  • HTTPS 证书已过期、证书链不完整或证书域名不匹配;
  • TLS 协议或加密套件不受支付平台支持;
  • 防火墙、WAF、CDN 或 API 网关拦截了支付平台的请求;
  • 接口限制了来源 IP,但白名单不完整;
  • 回调地址发生 301、302 或 307 重定向;
  • 网关只允许 GET,而支付平台发送的是 POST;
  • 接口依赖登录态、Cookie、Token 或 CSRF 校验;
  • 回调端口未向公网开放。

回调接口通常应使用无需用户登录即可访问的公网 HTTPS 地址,并按照支付平台的要求校验签名,以保证请求安全。

3. 请求已经到达,但在进入业务代码前被拒绝

排查时要同时查看 CDN、负载均衡、网关、容器和应用日志。请求可能在以下环节被拦截:

  • 请求体大小限制;
  • Content-Type 不匹配;
  • CSRF 防护;
  • 参数校验失败;
  • 限流规则;
  • User-Agent 或来源规则;
  • 路由配置错误;
  • HTTP 方法不匹配;
  • 网关超时;
  • 应用实例未注册或健康检查异常。

如果只看业务日志,很容易把“请求被网关拒绝”误判为“支付平台没有发送回调”。

4. 请求体被提前读取,导致验签失败

不少框架中的请求体只能读取一次。如果日志过滤器、网关插件或参数解析器提前读取了原始请求体,业务代码再次读取时可能只能得到空内容。

支付通知验签通常要使用支付平台指定的原始报文、请求头和证书信息。除非支付渠道明确允许,否则不能先把 JSON 转成对象,再修改字段顺序或重新序列化后进行验签。

5. 验签参数或商户配置不一致

常见问题有:

  • 使用了错误的商户号、应用 ID 或平台证书;
  • 在生产环境中使用了测试环境的密钥;
  • 平台证书已经更新,本地仍在使用旧证书;
  • 签名算法配置错误;
  • 请求头中的时间戳、随机串、签名等字段读取错误;
  • 字符编码处理不一致;
  • 接口要求对原始报文验签,代码却对解密后的内容验签;
  • 多商户场景中选用了错误的商户配置。

不要为了让回调“先跑通”而关闭验签。公网回调接口不校验签名时,攻击者可能伪造支付成功通知。

6. 业务处理发生异常,但没有留下有效日志

通知到达后,以下问题都可能导致事务回滚:

  • 查询不到商户订单;
  • 订单号映射错误;
  • 金额或币种校验不一致;
  • 数据库连接异常;
  • 状态字段不符合预期;
  • 重复通知引发唯一键冲突;
  • 消息队列发送失败;
  • 出现空指针或反序列化异常;
  • 统一异常处理器把异常转换成了错误的 HTTP 响应。

回调入口应记录可关联的信息,例如商户订单号、支付平台订单号、请求时间和处理结果,但不要记录密钥或不必要的敏感数据。

7. 返回内容不符合支付平台要求

支付平台一般要求回调接口在规定时间内返回指定的成功响应。不同渠道和 API 版本要求的 HTTP 状态码、响应文本或 JSON 格式可能不同,应以对应渠道的官方文档为准。

即使订单已经更新,只要返回值不符合要求,支付平台仍可能认为通知失败并继续重试。如果业务处理失败,接口却错误地返回了成功,支付平台可能停止重试,导致订单状态长期不一致。

8. 将异步通知当成实时同步调用

异步通知可能延迟,也可能重复发送或乱序到达。回调接口必须支持幂等处理,不能假设:

  • 通知只会发送一次;
  • 通知一定紧跟客户端支付完成;
  • 同一订单的通知一定按照状态变化顺序到达;
  • 首次通知失败后,支付平台一定会永久重试。

具体的重试策略和通知时限由支付渠道决定,不能自行推断。

推荐排查步骤

第一步:确认订单是否真的支付成功

通过支付平台提供的订单查询接口或商户后台查询该笔订单,确认:

  • 商户订单号是否正确;
  • 平台订单号是否存在;
  • 支付状态是否成功;
  • 支付金额和币种是否一致;
  • 订单属于哪个商户号和应用;
  • 是否存在通知记录或通知失败原因。

如果平台侧订单并未支付成功,就不应继续按“回调丢失”的方向排查。

第二步:核对下单请求

查看脱敏后的下单请求和响应,重点确认实际生效的回调地址。

如果最近修改过回调地址,应重新创建一笔订单进行测试。许多支付渠道会在创建订单时保存通知地址,修改本地配置通常不会影响已经创建的订单。

第三步:检查完整网络链路

按以下顺序查看访问日志:

支付平台
  -> DNS/CDN
  -> WAF/负载均衡
  -> API 网关
  -> Web 服务器
  -> 应用实例
  -> 数据库或消息队列

如果 CDN 或网关有访问记录,而应用没有记录,问题通常出在转发、路由或安全策略上。如果所有入口层都没有记录,应优先检查支付平台的通知记录、回调地址和公网连通性。

第四步:按真实请求方式测试

不要只在浏览器中打开回调地址。测试时应模拟实际的 HTTP 方法和 Content-Type,例如:

curl -i -X POST 'https://pay.example.com/api/payment/notify' \
  -H 'Content-Type: application/json' \
  --data '{"test":true}'

这种测试只能验证路由和请求处理能力,无法验证支付平台的真实签名。生产代码仍应拒绝验签失败的测试请求。

还要检查回调地址是否发生重定向:

curl -I 'https://pay.example.com/api/payment/notify'

如果返回 301 或 302,最好在支付平台中直接配置最终地址,不要依赖重定向。

第五步:增加入口日志

回调日志应尽量在入口处记录,并包含:

  • 请求时间;
  • HTTP 方法;
  • 请求路径;
  • Content-Type;
  • 可以安全记录的签名请求头;
  • 原始请求体的摘要或哈希;
  • 商户订单号;
  • 验签结果;
  • 业务处理结果;
  • 最终 HTTP 状态码和响应耗时。

如果原始报文包含个人信息或敏感数据,需要脱敏或限制日志访问权限。

第六步:检查验签和响应

确认验签所用的算法、证书和原始数据符合支付渠道当前 API 版本的要求。只有业务处理成功后,才能返回渠道规定的确认内容。

如果处理失败,应记录明确的错误,并根据支付渠道协议决定返回内容,让平台能够按规则重试。

第七步:使用主动查询兜底

对于长时间没有收到通知但已经支付的订单,可以通过订单查询接口进行补偿。常见做法包括:

  • 支付完成一段时间后发起延迟查询;
  • 定时扫描状态为“支付中”的订单;
  • 查询支付平台记录的权威状态;
  • 校验商户号、订单号、金额和币种;
  • 通过同一个幂等入口更新订单。

主动查询只是一种容错手段,不能代替异步通知的签名校验。

回调处理示例

下面是一个不依赖具体支付渠道的 Spring Boot 结构示例。verifySignature、parseNotification 和成功响应都必须根据实际支付平台的 SDK 或官方文档实现。

@RestController
@RequestMapping("/api/payment")
public class PaymentNotifyController {

    private final PaymentService paymentService;
    private final PaymentVerifier paymentVerifier;

    public PaymentNotifyController(
            PaymentService paymentService,
            PaymentVerifier paymentVerifier) {
        this.paymentService = paymentService;
        this.paymentVerifier = paymentVerifier;
    }

    @PostMapping("/notify")
    public ResponseEntity<String> notify(
            @RequestHeader HttpHeaders headers,
            @RequestBody byte[] rawBody) {

        try {
            boolean valid = paymentVerifier.verifySignature(headers, rawBody);
            if (!valid) {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                        .body("invalid signature");
            }

            PaymentNotification notification =
                    paymentVerifier.parseNotification(rawBody);

            paymentService.handlePaidNotification(notification);

            // 此处必须替换为支付平台要求的成功响应。
            return ResponseEntity.ok("success");
        } catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest().body("invalid request");
        } catch (Exception e) {
            // 记录异常后返回符合支付平台重试规则的失败响应。
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("failed");
        }
    }
}

业务处理必须保证幂等,并在更新订单前校验订单归属、金额、币种和支付状态:

@Transactional
public void handlePaidNotification(PaymentNotification notification) {
    PaymentOrder order = paymentOrderRepository
            .findByMerchantOrderNoForUpdate(notification.getMerchantOrderNo())
            .orElseThrow(() -> new IllegalArgumentException("order not found"));

    if (!order.getMerchantId().equals(notification.getMerchantId())) {
        throw new IllegalArgumentException("merchant mismatch");
    }

    if (order.getAmount().compareTo(notification.getAmount()) != 0) {
        throw new IllegalArgumentException("amount mismatch");
    }

    if (!order.getCurrency().equals(notification.getCurrency())) {
        throw new IllegalArgumentException("currency mismatch");
    }

    if (order.isPaid()) {
        return;
    }

    if (!notification.isPaymentSuccessful()) {
        return;
    }

    order.markPaid(
            notification.getPlatformTransactionId(),
            notification.getPaidAt()
    );

    paymentOrderRepository.save(order);
}

实际项目不一定要使用悲观锁,也可以选择唯一索引、条件更新或乐观锁。例如:

UPDATE payment_order
SET status = 'PAID',
    platform_transaction_id = ?,
    paid_at = ?
WHERE merchant_order_no = ?
  AND status <> 'PAID';

执行后可以根据受影响的行数判断是否为首次更新。无论使用哪种方式,都要保证重复通知不会造成重复发货、重复充值或重复记账。

注意事项

  • 不要根据 App 客户端返回的结果直接把订单改为已支付。
  • 不要为了排查问题而关闭签名验证。
  • 不要在回调接口中执行耗时过长的操作。核心状态写入数据库后,可以把后续业务交给可靠的消息队列处理。
  • 不要先返回成功,再异步更新核心支付状态。否则异步任务失败后,可能无法再依赖支付平台重试。
  • 不要把密钥、私钥、完整报文或用户敏感信息直接写入普通日志。
  • 回调接口要能处理重复通知和并发更新。
  • 成功响应格式、超时时间、签名算法、证书更新和重试规则,应以实际支付渠道及对应 API 版本的官方文档为准。
  • 如果支付平台后台有通知失败记录,应优先根据记录中的 HTTP 状态码、响应内容和失败时间定位问题,不能只凭本地访问测试认定接口正常。

备注:内容仅供参考。