PAYATHON 2026

网站接入支付宝后付款回调失败并显示未付款是什么原因?

支付老李

结论

支付宝已经扣款,但网站仍显示“未付款”,一般不是付款失败,而是网站没有正确接收、验证或处理支付宝发送的异步通知。

常见原因有:

  • 支付宝服务器无法访问 notify_url;
  • 网站把浏览器同步跳转当成了付款结果;
  • 程序读取回调参数的方式不对;
  • RSA2 签名验证失败;
  • 程序没有正确处理 trade_status;
  • 商户订单号、金额、app_id 或收款方信息不一致;
  • 回调接口没有返回支付宝要求的成功响应;
  • 数据库更新失败、事务回滚,或重复通知处理有误;
  • 前端查到了缓存数据或错误的订单记录。

排查时要结合支付宝的通知记录、网站回调日志和订单更新逻辑,不能只看付款后的跳转页面。

同步跳转不等于支付回调

支付完成后,通常会发生两件事:

  1. 浏览器跳转回网站,例如访问 return_url;
  2. 支付宝服务器向网站的 notify_url 发送异步通知。

用户可能关闭浏览器,网络也可能中断,跳转还可能被浏览器拦截。因此,网站不能把同步跳转作为最终付款依据,而应以验证通过的异步通知为准,然后查询自身的订单状态并展示结果。

还有一种情况是,用户已经回到网站,但异步通知尚未处理完成。此时页面短暂显示“未付款”属于正常的时序差异。前端可以轮询订单查询接口,但不能根据前端参数直接把订单改为已付款。

检查 notify_url 是否可以公网访问

支付宝服务器必须能够访问回调地址。以下问题都会导致通知失败:

  • 地址使用了 localhost、127.0.0.1 或局域网 IP;
  • 域名解析错误;
  • HTTPS 证书无效或证书链不完整;
  • 防火墙、WAF、CDN 或反向代理拦截了请求;
  • 回调接口要求登录,或者依赖 Cookie、验证码、CSRF Token;
  • 接口返回 301 或 302 跳转;
  • 请求超时,或者返回 404、500;
  • 路由只允许 GET,而实际通知使用 POST;
  • 沙箱环境和正式环境的应用、网关或密钥混在一起使用。

回调接口应该使用稳定的公网地址,并允许支付宝直接发送请求。例如:

https://example.com/payment/alipay/notify

排查时可以记录请求时间、来源、HTTP 状态码和必要的业务字段,但不要将私钥、完整签名或用户敏感信息写入公开日志。

正确读取回调参数

支付宝通知通常以表单形式提交。如果程序只解析 JSON,请求参数可能一直为空。

以 Express 为例,需要启用表单解析:

import express from "express";

const app = express();

app.use(express.urlencoded({ extended: false }));

app.post("/payment/alipay/notify", async (req, res) => {
  const params = req.body;

  console.log({
    out_trade_no: params.out_trade_no,
    trade_no: params.trade_no,
    trade_status: params.trade_status
  });

  res.type("text/plain").send("success");
});

不要先把参数转换成 JSON,再使用转换后的内容验签。签名验证应使用支付宝实际发送、并由框架正常解析出来的通知参数。

必须验证签名和业务字段

收到通知并不代表它可信。程序至少要完成这些检查:

  • 使用支付宝公钥验证签名;
  • 确认 app_id 属于当前应用;
  • 确认 out_trade_no 能匹配本地订单;
  • 确认通知金额与本地订单的应付金额相同;
  • 按当前产品协议核对 seller_id 等收款方标识;
  • 确认订单当前状态允许改为已支付;
  • 只处理符合业务要求的 trade_status。

常见的支付成功状态包括:

TRADE_SUCCESS
TRADE_FINISHED

究竟要处理哪些状态,应以接入产品的协议和业务规则为准。不能看到任意 trade_status 就更新订单。

比较金额时要避免浮点数误差,建议统一转换成最小货币单位:

function yuanToFen(value) {
  if (!/^\d+(\.\d{1,2})?$/.test(String(value))) {
    throw new Error("Invalid amount");
  }

  const [yuan, decimal = ""] = String(value).split(".");
  return Number(yuan) * 100 + Number(decimal.padEnd(2, "0"));
}

if (yuanToFen(notify.total_amount) !== order.amountFen) {
  throw new Error("Payment amount mismatch");
}

验签应优先使用与当前接入方案匹配的支付宝官方 SDK,不要自行调整字符编码、参数排序或签名算法。

还要分清支付宝公钥和应用公钥。验签通常配置支付宝公钥,商户发起请求时则使用应用私钥签名。

回调处理示例

下面是一份简化的处理结构。verifyAlipayNotify 代表官方 SDK 提供的通知验签能力,实际类名和调用方式取决于使用的语言及 SDK 版本。

app.post("/payment/alipay/notify", async (req, res) => {
  const notify = req.body;

  try {
    const signatureValid = verifyAlipayNotify(notify);

    if (!signatureValid) {
      return res.status(400).type("text/plain").send("failure");
    }

    if (notify.app_id !== config.alipayAppId) {
      return res.status(400).type("text/plain").send("failure");
    }

    if (
      notify.trade_status !== "TRADE_SUCCESS" &&
      notify.trade_status !== "TRADE_FINISHED"
    ) {
      return res.type("text/plain").send("success");
    }

    await database.transaction(async (tx) => {
      const order = await tx.orders.findForUpdate(notify.out_trade_no);

      if (!order) {
        throw new Error("Order not found");
      }

      if (yuanToFen(notify.total_amount) !== order.amountFen) {
        throw new Error("Payment amount mismatch");
      }

      if (order.status === "PAID") {
        return;
      }

      await tx.orders.markAsPaid({
        orderNo: order.orderNo,
        alipayTradeNo: notify.trade_no,
        paidAt: notify.gmt_payment
      });

      await tx.paymentNotifications.save({
        notifyId: notify.notify_id,
        orderNo: order.orderNo,
        tradeNo: notify.trade_no
      });
    });

    return res.type("text/plain").send("success");
  } catch (error) {
    console.error("Alipay notify processing failed", {
      message: error.message,
      out_trade_no: notify.out_trade_no,
      trade_no: notify.trade_no
    });

    return res.status(500).type("text/plain").send("failure");
  }
});

示例中的数据库接口需要替换成项目实际使用的 ORM 或数据库操作。

成功处理后应返回正确响应

回调处理成功后,需要按照当前支付宝接口规范返回指定内容。常见的响应是纯文本:

success

不要返回 HTML 页面、JSON 对象或带有额外空格的调试信息,例如:

{"status":"success"}

如果支付宝没有识别出成功响应,通常会继续重试通知。因此,回调处理必须具备幂等性。同一笔支付通知即使到达多次,也只能更新一次订单,不能重复发货、充值或增加余额。

可以为 trade_no、notify_id 或业务订单号建立唯一约束,具体方案要结合实际支付场景设计。

数据库更新失败也会显示未付款

即使验签通过,以下问题仍可能让订单状态保持不变:

  • 查询订单时使用了错误的 out_trade_no;
  • 数据库事务最终回滚;
  • 字段长度不足,导致保存 trade_no 失败;
  • 订单状态条件过严,更新语句影响的行数为零;
  • 回调写入主库,但页面读取了延迟较高的从库;
  • 页面或接口缓存了旧状态;
  • 回调进程异常退出,却提前返回了 success;
  • 订单已经更新,但后续业务异常导致整个事务回滚。

日志最好分别记录这些节点:

收到通知
验签通过
订单匹配成功
金额校验通过
订单更新成功
返回 success

这样更容易确认处理停在了哪一步。

推荐排查顺序

  1. 到支付宝商户平台查看这笔订单的异步通知发送记录和响应结果。
  2. 检查服务器访问日志,确认回调请求是否到达。
  3. 确认接口可以解析表单参数,而不是只接收 JSON。
  4. 核对应用环境、app_id、支付宝公钥、签名算法和字符集。
  5. 检查 trade_status、out_trade_no、total_amount 和收款方信息。
  6. 查看数据库事务结果和更新语句影响的行数。
  7. 确认处理成功后返回的是规范要求的纯文本响应。
  8. 检查前端是否通过后端订单查询接口获取最新状态,并排除缓存和主从延迟。

不要为了让页面显示“已付款”而跳过验签,也不要直接相信浏览器传回的金额和订单状态,否则会留下伪造支付结果、篡改金额和重复入账等严重风险。

备注:内容仅供参考。