PAYATHON 2026

手机网站支付关闭订单不回调及消息通知幂等处理

支付小周

结论

  1. timeout_express 表示支付宝交易允许付款的时间范围。它不是业务系统的定时器,也不能保证到期瞬间一定会收到 TRADE_CLOSED 通知。业务系统需要自行维护订单超时时间,到期后主动查询交易状态,并在必要时调用关单接口。
  2. 支付入账主要依据 TRADE_SUCCESS。TRADE_FINISHED 表示交易已经结束,通常说明此前已支付成功,但业务系统不应等到这一状态才发货或开通服务。
  3. 异步通知需要围绕业务订单和状态流转做幂等处理。notify_id 可用于辅助记录和排查,但不适合作为唯一的去重依据。
  4. 支付结果不能只依赖异步通知。更稳妥的方案是以异步通知推动状态,用主动查询进行补偿,并通过数据库条件更新保证幂等。

为什么订单过期后没有收到关闭通知

timeout_express 控制支付宝侧的可支付期限。超过期限后,订单不能继续付款,但业务系统仍可能无法按预期收到关闭通知,常见原因包括:

  • 交易的实际关闭时间可能晚于业务系统计算的到期时间,无法保证精确到秒。
  • 异步通知通过网络传递,可能出现延迟、重复或发送失败。
  • notify_url 无法从公网访问,HTTPS 配置异常,发生重定向,或响应超时。
  • 回调地址没有返回支付宝要求的成功响应。支付宝会继续重试,但业务系统最终仍可能没有正确处理通知。
  • 程序在签名验证失败后直接丢弃通知,又没有留下足够的日志。
  • 创建支付请求时没有正确传入异步通知地址,或相关参数未按当前接口要求生效。
  • 用户没有真正进入支付宝收银台。部分业务链路虽然已经创建商户订单,但支付宝交易可能尚未成功建立。
  • 支付宝已经关闭交易,但通知处理程序受应用发布、网关拦截或数据库异常等因素影响,没有完成处理。

因此,本地订单不能依靠 TRADE_CLOSED 通知准时释放库存或关闭订单。

推荐的超时关单流程

创建业务订单时,应同时保存本地过期时间,例如 expire_at。定时任务扫描已经到期、状态仍为“待支付”的订单,并按以下流程处理:

  1. 调用 alipay.trade.query 查询支付宝交易状态。
  2. 返回 TRADE_SUCCESS 或 TRADE_FINISHED 时,按支付成功处理。
  3. 返回 WAIT_BUYER_PAY 时,调用 alipay.trade.close 主动关闭交易。
  4. 交易不存在时,根据业务规则关闭本地订单,同时保留查询结果和请求日志。
  5. 使用数据库条件更新,只允许“待支付”订单变更为“已支付”或“已关闭”,避免并发覆盖。
  6. 查询或关单失败时安排重试,不要直接认定交易已经关闭。

伪代码如下:

void handleExpiredOrder(Order order) {
    if (order.getStatus() != OrderStatus.PENDING) {
        return;
    }

    AlipayTradeQueryResponse response = queryAlipayTrade(order.getOrderNo());

    if (response.isSuccess()) {
        switch (response.getTradeStatus()) {
            case "TRADE_SUCCESS":
            case "TRADE_FINISHED":
                confirmPayment(order, response.getTradeNo(), response.getTotalAmount());
                return;

            case "WAIT_BUYER_PAY":
                closeAlipayTrade(order.getOrderNo());
                closeLocalOrderIfPending(order.getOrderNo());
                return;

            case "TRADE_CLOSED":
                closeLocalOrderIfPending(order.getOrderNo());
                return;

            default:
                scheduleRetry(order.getOrderNo());
                return;
        }
    }

    if (isTradeNotExist(response)) {
        closeLocalOrderIfPending(order.getOrderNo());
    } else {
        scheduleRetry(order.getOrderNo());
    }
}

主动关单和用户支付可能同时发生,因此不能简单地先修改本地订单,再调用支付宝关单。无论处理查询结果、关单结果还是异步通知,都需要重新检查订单的当前状态。

如何处理 TRADE_SUCCESS 和 TRADE_FINISHED

常见交易状态的业务含义如下:

  • WAIT_BUYER_PAY:交易已创建,正在等待买家付款。
  • TRADE_CLOSED:未付款交易已关闭,或者交易已按接口规则关闭。
  • TRADE_SUCCESS:付款成功,交易后续仍可能处于可退款等阶段。
  • TRADE_FINISHED:交易已经结束,通常不能再通过普通交易流程继续操作。

收到 TRADE_SUCCESS 后即可确认支付到账,并执行发货、充值或开通会员等业务动作。

如果系统第一次收到的状态是 TRADE_FINISHED,也不能直接忽略。该状态通常表示交易已经成功完成,处理程序应先检查本地订单:

  • 本地已经支付:只更新必要的终态信息,不再重复发货。
  • 本地仍为待支付:校验交易信息后补记支付成功,再把交易阶段更新为已结束。
  • 本地已经关闭:进入人工或自动对账流程,不能直接覆盖原有状态。

状态流转可以设计为:

PENDING -> PAID -> FINISHED
PENDING -> CLOSED

FINISHED 是交易生命周期的终态,但商户确认首次收款不需要等到这个状态。

回调验签和业务校验

收到通知后,不能只检查 trade_status,至少还要完成以下校验:

  1. 使用支付宝公钥验证签名。
  2. 核对 app_id 是否属于当前应用。
  3. 核对 seller_id 或收款账号是否属于当前商户。
  4. 根据 out_trade_no 查找本地订单。
  5. 核对 total_amount、币种和订单关联商品。
  6. 核对 trade_no,避免同一个支付宝交易号绑定到不同的业务订单。
  7. 检查当前本地订单状态是否允许此次状态迁移。

以 Java SDK 为例,验签代码可以写成:

boolean verified = AlipaySignature.rsaCheckV1(
    requestParams,
    alipayPublicKey,
    "UTF-8",
    "RSA2"
);

if (!verified) {
    throw new SecurityException("Invalid Alipay notification signature");
}

具体的方法签名应以项目实际使用的支付宝 SDK 版本为准,不要自行拼接待验签字符串。

幂等处理不能只依赖 notify_id

notify_id 是通知层面的标识,适合记录在通知日志中,用来定位某次回调,但不适合作为支付业务唯一的幂等键,原因有以下几点:

  • 消息去重与业务幂等不是一回事。
  • 同一笔交易可能因状态变化产生多次通知。
  • 系统也可能通过主动查询、人工补单或对账文件确认支付,而这些流程没有相同的 notify_id。
  • 即使通知标识不同,同一个业务订单也不能重复发货或重复入账。

可以分成消息层和业务层处理:

  • 消息层记录 notify_id、trade_no、通知状态和原始报文摘要,用于审计和快速过滤重复请求。
  • 业务层以 out_trade_no 为核心,通过数据库唯一约束和条件更新,保证订单只能成功入账一次。

例如:

UPDATE payment_order
SET status = 'PAID',
    alipay_trade_no = :tradeNo,
    paid_at = :paidAt,
    updated_at = CURRENT_TIMESTAMP
WHERE order_no = :orderNo
  AND status = 'PENDING';

随后检查受影响的行数:

int updated = paymentOrderRepository.markPaidIfPending(
    orderNo,
    tradeNo,
    paidAt
);

if (updated == 1) {
    createFulfillmentEvent(orderNo);
} else {
    verifyExistingPaymentResult(orderNo, tradeNo);
}

数据库还应设置必要的唯一约束:

ALTER TABLE payment_order
    ADD CONSTRAINT uk_payment_order_no UNIQUE (order_no);

ALTER TABLE payment_order
    ADD CONSTRAINT uk_alipay_trade_no UNIQUE (alipay_trade_no);

如果支付成功后还要发货、充值或发送消息,最好使用本地事务消息或 Outbox 模式,让“订单改为已支付”和“生成后续业务事件”在同一个数据库事务中完成:

@Transactional
public void confirmPayment(PaymentNotification notification) {
    int updated = orderRepository.markPaidIfPending(
        notification.getOutTradeNo(),
        notification.getTradeNo(),
        notification.getPaidAt()
    );

    if (updated == 1) {
        outboxRepository.insert(
            "PAYMENT_SUCCEEDED",
            notification.getOutTradeNo()
        );
    }
}

消费 PAYMENT_SUCCEEDED 事件时仍需保证幂等。例如,可以为“订单号 + 业务动作类型”建立唯一索引,防止重复发货。

回调接口的正确响应方式

回调处理成功后,应按支付宝接口要求返回纯文本成功标识,通常为:

success

不要返回 JSON、HTML 页面或额外的提示文字。处理失败时也不要伪造成功响应,否则支付宝可能停止重试。

回调接口还需注意以下事项:

  • 不要在回调线程中执行耗时的发货、短信或第三方调用。
  • 完成验签和核心状态落库后,尽快返回响应。
  • 保存必要的结构化日志,但不要记录私钥等敏感信息。
  • 遇到签名失败、金额不一致或商户身份不一致等情况时及时告警。
  • 回调地址不能依赖登录状态,也不要执行浏览器跳转。
  • return_url 是用户浏览器的同步跳转地址,不能作为支付成功的依据。
  • 长期保留定时查询和对账任务,用于修复通知丢失、程序异常和极端并发造成的问题。

支付系统需要能够应对通知延迟、重复、乱序甚至丢失。异步通知用于及时推动状态,主动查询和对账用于补偿,数据库状态机与唯一约束则保证每项业务只执行一次。

备注:内容仅供参考。