PAYATHON 2026

ATG - Payment 处理中如何锁定订单

支付小周

结论

不要把浏览器重定向当作支付完成的唯一依据,也不要在固定时间后直接把订单从 waiting for payment 恢复为 INCOMPLETE。

更稳妥的设计是:

  1. 用户发起支付时,冻结订单内容,并生成唯一的支付尝试记录。
  2. 将订单从可编辑状态切换为自定义的 PAYMENT_PENDING 状态。
  3. 通过网关的服务端通知或主动查询确认支付结果。
  4. 超时只说明“结果未知”,不能直接判定支付失败。
  5. 超时任务应先查询支付网关。只有明确未支付或支付会话已经失效,才能取消订单或重新发起支付。
  6. 如果用户需要继续购物,为其创建新的购物车,不要重新开放正在支付的旧订单。

这套设计可以避免用户在支付期间修改商品或重复支付,也能防止出现“钱已扣,但订单被恢复”的情况。

为什么不能只依赖页面跳转

支付完成后,网关会把浏览器重定向到商城。这个跳转只能说明用户访问了返回地址,不能可靠证明交易已经完成。

实际运行中可能出现以下情况:

  • 用户关闭支付页面,没有返回商城。
  • 网络中断,支付成功但重定向失败。
  • 用户重复刷新返回页面。
  • 网关的服务端通知晚于浏览器返回。
  • 浏览器返回成功,但网关后续确认失败。
  • 攻击者伪造返回参数。
  • 超时任务已经执行,迟到的成功通知才送达。

因此,浏览器返回地址只适合显示“正在确认支付”。最终状态应以经过签名验证的服务端通知,或者支付网关的订单查询结果为准。

建议的状态模型

不要只在 INCOMPLETE 和“支付成功”之间增加一个等待状态。支付结果未知、确认成功和确认失败需要分别表示。

INCOMPLETE
    |
    | 发起支付并冻结订单
    v
PAYMENT_PENDING
    |
    +-- 网关确认成功 ------> PAYMENT_CONFIRMED
    |
    +-- 网关确认失败 ------> PAYMENT_FAILED
    |
    +-- 等待超过检查时间 --> PAYMENT_UNKNOWN
                                |
                                +-- 查询后成功 --> PAYMENT_CONFIRMED
                                +-- 查询后失败 --> PAYMENT_FAILED
                                +-- 会话过期 ----> CANCELLED

各状态的含义如下:

  • PAYMENT_PENDING:支付请求已经创建,订单不可修改。
  • PAYMENT_UNKNOWN:暂时没有得到确定结果,需要继续查询或转人工处理。
  • PAYMENT_CONFIRMED:网关已经明确确认支付成功。
  • PAYMENT_FAILED:网关明确返回失败或取消。
  • CANCELLED:支付会话已经失效,并且已确认不存在成功交易。

状态名称可以按照现有 ATG 项目规范调整。名称本身并不重要,重要的是保留“支付结果未知”这一语义。

发起支付时如何锁定订单

发起支付时,应在同一个事务中完成以下操作:

  1. 重新读取订单,确认订单仍处于 INCOMPLETE 状态。
  2. 重新计算价格、优惠、运费和税费。
  3. 校验库存和支付金额。
  4. 创建不可重复的 paymentAttemptId。
  5. 保存网关交易号、请求金额、币种和创建时间。
  6. 将订单切换为 PAYMENT_PENDING。
  7. 提交事务后,再跳转到支付网关。

下面是示意代码。具体的 repository 属性和 pipeline chain 名称取决于项目实现,不应直接照搬:

public PaymentRedirect startPayment(String orderId) {
    return transactionTemplate.execute(status -> {
        Order order = orderManager.loadOrder(orderId);

        if (!"INCOMPLETE".equals(order.getState())) {
            throw new IllegalStateException(
                "Order is not eligible for payment: " + order.getState()
            );
        }

        validateOrder(order);
        repriceOrder(order);

        String paymentAttemptId = UUID.randomUUID().toString();

        PaymentAttempt attempt = paymentAttemptRepository.create();
        attempt.setId(paymentAttemptId);
        attempt.setOrderId(order.getId());
        attempt.setAmount(order.getPriceInfo().getTotal());
        attempt.setCurrencyCode(order.getPriceInfo().getCurrencyCode());
        attempt.setStatus("CREATED");
        attempt.setCreatedAt(Instant.now());

        order.setState("PAYMENT_PENDING");
        order.setPropertyValue("activePaymentAttemptId", paymentAttemptId);
        order.setPropertyValue("paymentPendingSince", new Date());

        paymentAttemptRepository.save(attempt);
        orderManager.updateOrder(order);

        return paymentGateway.createRedirect(order, attempt);
    });
}

ATG 项目通常会通过 checkout pipeline、OrderManager、repository 事务和项目自己的订单状态处理器来完成这些操作。状态变更应集中在统一的服务或 pipeline processor 中,避免 servlet、form handler 和定时任务各自直接修改订单。

需要注意的是,数据库事务只能保护发起支付这一小段流程,不能在用户停留于第三方页面期间一直持有数据库锁。这里的“锁定订单”是指通过持久化状态和业务规则禁止修改,而不是长期占用数据库锁。

所有修改购物车的入口都必须检查订单状态:

public void assertOrderEditable(Order order) {
    if (!"INCOMPLETE".equals(order.getState())) {
        throw new IllegalStateException(
            "Order cannot be modified while payment is in progress"
        );
    }
}

这项检查不能只放在页面按钮上。修改数量、删除商品、应用优惠券、修改配送地址和重新定价等服务端操作,都应执行相同的校验。

支付期间如何允许用户继续购物

订单进入 PAYMENT_PENDING 后,不宜继续把它当作当前购物车。可以将该订单保存为固定的结账快照,同时为用户创建一个新的 INCOMPLETE 订单。

这样,即使用户在另一个浏览器或标签页中继续购物,修改的也只是新购物车,不会影响正在支付的订单。

以下关联需要明确处理:

  • 待支付订单仍属于该用户。
  • 新购物车使用独立的 order ID。
  • 原订单中的价格、优惠和配送信息保持冻结。
  • 库存是在发起支付时预留,还是在支付成功后扣减,由库存策略决定。
  • 支付失败后,如果需要把商品复制回新购物车,应由用户主动选择,以免覆盖后来加入的商品。

回调处理必须幂等

支付网关可能多次发送同一条通知,浏览器也可能重复访问返回地址。因此,支付确认逻辑必须能够安全地重复执行。

public void handlePaymentNotification(GatewayNotification notification) {
    verifySignature(notification);

    transactionTemplate.executeWithoutResult(status -> {
        PaymentAttempt attempt =
            paymentAttemptRepository.findByGatewayTransactionId(
                notification.getTransactionId()
            );

        if (attempt == null) {
            throw new IllegalArgumentException("Unknown payment transaction");
        }

        Order order = orderManager.loadOrder(attempt.getOrderId());

        validateAmountAndCurrency(notification, attempt);

        if ("CONFIRMED".equals(attempt.getStatus())) {
            return;
        }

        if (notification.isSuccessful()) {
            attempt.setStatus("CONFIRMED");
            attempt.setConfirmedAt(Instant.now());

            order.setState("PAYMENT_CONFIRMED");

            paymentAttemptRepository.save(attempt);
            orderManager.updateOrder(order);

            continueOrderFulfillment(order);
        } else if (notification.isFinalFailure()) {
            attempt.setStatus("FAILED");
            order.setState("PAYMENT_FAILED");

            paymentAttemptRepository.save(attempt);
            orderManager.updateOrder(order);
        }
    });
}

生产环境还要防止两个节点同时处理同一个订单。可以使用 repository 乐观锁、版本字段、条件更新,或者项目已有的分布式锁机制。不要只依赖 JVM 内的 synchronized,因为 ATG 经常采用集群部署。

回调至少需要校验以下内容:

  • 网关签名或消息认证信息。
  • 商户订单号和支付尝试编号。
  • 网关交易号。
  • 金额和币种。
  • 商户账户。
  • 当前支付状态是否允许执行该状态转换。

超时应该如何设置

没有必要猜测“一次支付最多需要多少分钟”。应把下面三个时间概念分开处理:

  • 用户支付会话的有效期:由支付网关或业务规则决定,例如支付链接何时失效。
  • 首次检查时间:订单进入 PAYMENT_PENDING 后,多久开始主动查询。
  • 最终保留时间:支付结果长期未知时,多久转入人工处理或关闭流程。

首次检查可以安排得较早,但达到检查时间后不能直接恢复订单。正确的处理流程是:

发现 PAYMENT_PENDING 超过检查时间
              |
              v
      调用网关查询接口
              |
     +--------+---------+
     |        |         |
   成功      失败      未知
     |        |         |
  确认订单  标记失败   稍后重试
                      或人工处理

定时任务的核心逻辑可以写成:

public void reconcilePendingPayment(PaymentAttempt attempt) {
    GatewayPaymentStatus result =
        paymentGateway.query(attempt.getGatewayTransactionId());

    switch (result) {
        case SUCCEEDED:
            confirmPayment(attempt);
            break;

        case FAILED:
        case CANCELLED:
        case EXPIRED:
            failPayment(attempt);
            break;

        case PENDING:
        case UNKNOWN:
            scheduleNextCheck(attempt);
            break;

        default:
            throw new IllegalStateException(
                "Unsupported gateway status: " + result
            );
    }
}

具体等待时间应根据支付网关文档、支付方式和业务风险确定。银行卡、银行转账、扫码支付以及需要额外认证的支付方式,完成时间可能完全不同。可以针对不同支付方式配置不同策略,不必设置统一的全局分钟数。

查询重试可以使用递增间隔。开始时查询得频繁一些,之后逐渐降低频率。具体间隔和最大查询次数需要遵守支付网关的限流规则及查询接口约束。

什么时候可以恢复订单

只有同时满足以下条件,才适合将订单恢复到可以再次支付的状态:

  • 网关明确返回失败、取消或过期。
  • 已经确认没有成功扣款。
  • 当前支付尝试已经标记为终态。
  • 原订单中的商品和价格仍然有效。
  • 当前没有新的支付尝试正在进行。

即使满足这些条件,也不一定要把订单恢复为原来的 INCOMPLETE。通常更安全的做法是:

  • 保留原订单和失败的支付记录,以便审计。
  • 提供“重新支付”操作,并创建新的 paymentAttemptId。
  • 如果需要修改订单内容,将商品复制到新的购物车。
  • 重新计算库存、价格、优惠、税费和运费。

绝不能只因为本地超时就直接开放原订单编辑。否则,网关可能稍后确认支付成功,而用户已经修改了订单金额或商品。

还需要处理的边界情况

重复点击支付

创建支付请求前,应检查当前是否已有活动的支付尝试。同一订单在同一时刻只能有一个有效的支付会话。调用网关创建交易时,也应使用网关提供的幂等键。

支付金额变化

订单进入 PAYMENT_PENDING 后,支付金额必须冻结。收到回调时,应比较通知中的金额、币种和本地支付记录,不能采用用户从返回页面提交的金额。

库存预留

如果在发起支付时预留库存,需要为库存预留设置独立的有效期。释放库存前仍要查询支付结果,避免用户已经付款,库存却被释放。库存预留超时和支付状态超时是两个不同的概念,不能混为一谈。

迟到的成功通知

即使订单已经标记为 PAYMENT_FAILED 或 CANCELLED,仍可能收到迟到的成功通知。系统不能直接忽略这类通知,而应进入补偿流程,例如重新占用库存、创建异常订单、自动退款或转人工处理。具体采用哪种策略,取决于网关是否允许在支付成功后自动退款。

集群调度

如果定时任务运行在多个 ATG 实例上,需要保证同一条支付记录不会被重复对账。可以使用单节点调度、数据库抢占标记、条件更新,或者已有的集群任务机制。

推荐的整体方案

整个方案的重点是把“订单”和“支付尝试”分开建模。订单负责保存商品和履约信息,每次跳转到支付网关时,都创建一条独立的支付尝试记录。一个订单可以有多次支付尝试,但同一时间只能存在一个活动尝试。

最终判断规则如下:

浏览器返回:只改变页面展示,不直接确认支付
服务端通知:验证后更新支付状态
主动查询:处理通知丢失和长期未决状态
本地超时:触发对账,不直接判断失败
订单修改:仅允许处于可编辑状态的订单

这套设计不依赖一个难以确定的固定超时时间,同时能够处理用户关闭页面、通知丢失、重复回调、跨浏览器修改购物车和支付结果迟到等情况。

备注:内容仅供参考。