PAYATHON 2026

如何在后台处理交易时显示“支付处理中”页面?

支付老李

结论

可以把 checkout 设计为一套状态机:提交支付任务,显示等待页,定时刷新整个页面查询状态,完成后再重定向。

支付请求由 Resque worker 在后台执行。“支付处理中”页面通过 HTTP Refresh 响应头或 HTML <meta http-equiv="refresh"> 定期重新访问状态接口,不需要 JavaScript。状态接口只查询本地数据库,并根据结果响应:

  • 仍在处理时,继续返回等待页。
  • 支付成功时,使用 303 See Other 跳转到订单确认页。
  • 支付失败时,跳转到失败页或重新支付页面。
  • 状态未知或等待超时时,显示人工刷新入口和订单查询方式,方便用户恢复操作。

在普通 HTTP 模型下,后台任务完成后,服务器无法主动修改已经发送给用户的页面。没有 JavaScript、长连接或 server push,浏览器只能再次发起请求来获取最新状态。整页自动刷新是兼容无 JavaScript 客户端的常见方案,但支付流程是否可靠,仍取决于服务端状态持久化、幂等处理和恢复机制。

页面流转方式

建议按以下流程处理:

  1. 用户提交 checkout。
  2. Web server 在数据库中创建一条状态为 queued 的 payment_attempt。
  3. 将这条记录的 ID 放入 Resque 队列。
  4. 立即返回”支付处理中”页面,不等待 payment gateway 响应。
  5. 浏览器每隔几秒重新请求支付状态页面。
  6. Resque worker 调用 payment gateway,并把结果写入数据库。
  7. 状态页面发现支付成功后,使用 303 重定向到订单确认页。

后台 worker 负责推进订单和支付状态,不负责操作某个浏览器页面。浏览器在下一次刷新时读取服务端保存的状态,再进入对应页面。

Rails 与 Resque 示例

以下示例只展示核心结构。具体字段和异常类型需要根据实际使用的 payment gateway 调整。

提交支付时,先持久化支付任务,再交给 Resque:

class PaymentsController < ApplicationController
  def create
    payment_attempt = nil

    PaymentAttempt.transaction do
      payment_attempt = PaymentAttempt.create!(
        order_id: params[:order_id],
        customer_id: current_customer.id,
        status: "queued",
        idempotency_key: SecureRandom.uuid
      )
    end

    Resque.enqueue(ProcessPayment, payment_attempt.id)

    redirect_to processing_payment_path(payment_attempt),
                status: :see_other
  end
end

处理页面对应的 action 只读取本地状态,不调用 payment gateway:

class PaymentsController < ApplicationController
  def processing
    payment_attempt = current_customer.payment_attempts.find(params[:id])

    case payment_attempt.status
    when "succeeded"
      redirect_to order_confirmation_path(payment_attempt.order_id),
                  status: :see_other
    when "failed"
      redirect_to payment_failure_path(payment_attempt),
                  status: :see_other
    when "queued", "processing"
      response.set_header("Refresh", "5")
      render :processing, locals: { payment_attempt: payment_attempt }
    else
      render :payment_status_unknown,
             locals: { payment_attempt: payment_attempt },
             status: :accepted
    end
  end
end

等待页面可以同时提供 HTML fallback:

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta http-equiv="refresh"
        content="5;url=<%= processing_payment_path(payment_attempt) %>">
  <title>支付处理中</title>
</head>
<body>
  <h1>支付处理中</h1>

  <p>银行或支付机构正在确认这笔交易,请不要重复提交付款。</p>

  <p>
    如果页面没有自动更新,
    <a href="<%= processing_payment_path(payment_attempt) %>">点击这里查询支付结果</a>。
  </p>
</body>
</html>

如果 HTTP Refresh 头已经指定了刷新地址,meta refresh 可以作为兼容性补充。刷新间隔不宜太短,应根据业务量和用户能够接受的等待时间设置,也可以随着等待时间逐步延长。

后台 worker 负责更新支付状态:

class ProcessPayment
  @queue = :payments

  def self.perform(payment_attempt_id)
    payment_attempt = PaymentAttempt.find(payment_attempt_id)

    claimed = PaymentAttempt
      .where(id: payment_attempt.id, status: "queued")
      .update_all(status: "processing", started_at: Time.current)

    return if claimed.zero?

    result = PaymentGateway.charge(
      order_id: payment_attempt.order_id,
      idempotency_key: payment_attempt.idempotency_key
    )

    PaymentAttempt.transaction do
      payment_attempt.lock!

      payment_attempt.update!(
        status: "succeeded",
        gateway_transaction_id: result.transaction_id,
        completed_at: Time.current
      )

      Order.where(
        id: payment_attempt.order_id,
        payment_status: "pending"
      ).update_all(
        payment_status: "paid",
        paid_at: Time.current
      )
    end
  rescue PaymentGateway::Declined => error
    payment_attempt&.update!(
      status: "failed",
      failure_code: error.code,
      completed_at: Time.current
    )
  rescue PaymentGateway::Timeout, PaymentGateway::Unavailable => error
    payment_attempt&.update!(
      status: "unknown",
      failure_code: error.class.name
    )

    raise
  end
end

异常分类要以支付服务商的实际语义为准。网络超时不等于支付失败。请求可能已经到达 payment gateway,只是响应没有成功返回。遇到这种情况,应先通过幂等键或交易查询接口核对结果,不能直接再次扣款。

用状态机代替页面会话

支付流程至少要保存以下状态:

queued
  └── processing
        ├── succeeded
        ├── failed
        └── unknown

unknown 表示暂时无法确定支付结果,并非已经确认失败。例如,payment gateway 可能在扣款后断开连接。worker 虽然收到超时,客户却可能已经付款。

支付状态必须保存在共享数据库中,不能只放在以下位置:

  • 浏览器 session;
  • Web server 内存;
  • Resque job 的临时执行上下文;
  • 某一台机器的本地缓存。

这样,即使用户关闭页面、Web server 重启或 worker 重试,系统仍能根据持久化状态继续处理。

幂等性决定支付能否安全重试

后台任务通常采用”至少执行一次”的交付语义。同一个 job 可能因为 worker 崩溃、超时或队列重试而执行多次,所以支付操作必须支持幂等。

应优先把唯一的 idempotency_key 传给 payment gateway。如果支付服务商支持幂等请求,使用同一个键重复提交时,不应产生第二笔扣款。

本地数据库也要增加唯一约束,例如限制一个订单只能存在一笔生效中的支付:

add_index :payment_attempts,
          :idempotency_key,
          unique: true

add_index :payment_attempts,
          [:order_id, :status]

只使用 return if status == "succeeded" 无法阻止并发扣款,因为两个 worker 可能同时读到 queued。需要通过条件更新、数据库锁或其他原子抢占机制,保证只有一个 worker 获得执行权。

处理队列与数据库的一致性

“先写数据库,再入队”仍有一个短暂的风险窗口:数据库提交成功后,进程可能在执行 Resque.enqueue 之前崩溃。此时支付记录会停留在 queued,队列里却没有对应任务。

更可靠的方案是使用 transactional outbox:

  1. 在同一个数据库事务中写入 payment_attempt 和 outbox 事件。
  2. 由独立 dispatcher 持续把 outbox 事件投递给 Resque。
  3. 投递成功后,将事件标记为已发送。
  4. 如果发生重复投递,由 worker 的幂等逻辑处理。

如果暂时不实现 outbox,至少要增加定时修复任务,扫描长时间处于 queued 且尚未开始处理的记录,并将它们重新入队。

支付成功不能依赖浏览器继续停留

客户可能关闭标签页、失去网络连接,自动刷新也可能被浏览器策略、代理或辅助技术阻止。支付成功后的服务端工作必须独立完成,包括:

  • 将订单标记为已支付;
  • 锁定或扣减库存;
  • 生成履约任务;
  • 发送确认邮件;
  • 在账户订单列表中显示最终状态。

确认页只负责展示结果,不应承担最终确认订单的业务逻辑。即使客户没有再次访问”支付处理中”页面,订单也必须能够正常完成。

提供可靠的恢复入口

Refresh 或 meta refresh 可以改善等待体验,但不能作为唯一的恢复方式。等待页面还需要提供:

  • 普通的”查询支付结果”链接;
  • 可以保存的订单号或支付查询编号;
  • 返回订单详情页的入口;
  • 不要重复付款的明确提示;
  • 支付完成后的邮件或短信通知;
  • 对长时间未完成交易的自动核对机制。

状态 URL 应与已登录客户或一次性安全 token 绑定。不能只凭连续数字 ID 查询,否则可能泄露其他客户的订单状态。

控制刷新产生的流量

促销期间,如果数百个等待页面每秒刷新一次,同样会给系统带来压力。状态接口应尽量轻量,只查询本地数据库或缓存,不能在每次刷新时再次调用 payment gateway。

刷新间隔可以逐步延长,例如:

前 30 秒:每 3~5 秒刷新
30~120 秒:每 10 秒刷新
超过 2 分钟:每 30 秒刷新,并显示恢复说明

如果需要动态调整间隔,服务端可以根据已等待时间生成下一次 Refresh 值。状态查询响应通常不应进入公共缓存,必要时可以设置:

Cache-Control: no-store

最终原则

在没有 JavaScript 的环境中,最实用的页面流转方式是定时刷新整个页面,再由服务端重定向。这个机制只解决浏览器如何获得支付结果,不能保证支付流程本身可靠。

可靠的支付流程需要满足这些条件:

  • 持久化支付状态;
  • 后台任务可以安全重试;
  • 向 payment gateway 发出的请求带有幂等键;
  • 将超时或未知状态与明确失败分开处理;
  • 订单完成逻辑不依赖浏览器保持在线;
  • 提供丢失任务扫描、交易对账和人工恢复入口。

做到这些以后,即使用户关闭页面、worker 重启或 payment gateway 长时间没有响应,交易也不会因为”处理中”页面停止跳转而丢失。

备注:内容仅供参考。