如何在后台处理交易时显示“支付处理中”页面?
结论
可以把 checkout 设计为一套状态机:提交支付任务,显示等待页,定时刷新整个页面查询状态,完成后再重定向。
支付请求由 Resque worker 在后台执行。“支付处理中”页面通过 HTTP Refresh 响应头或 HTML <meta http-equiv="refresh"> 定期重新访问状态接口,不需要 JavaScript。状态接口只查询本地数据库,并根据结果响应:
- 仍在处理时,继续返回等待页。
- 支付成功时,使用
303 See Other跳转到订单确认页。 - 支付失败时,跳转到失败页或重新支付页面。
- 状态未知或等待超时时,显示人工刷新入口和订单查询方式,方便用户恢复操作。
在普通 HTTP 模型下,后台任务完成后,服务器无法主动修改已经发送给用户的页面。没有 JavaScript、长连接或 server push,浏览器只能再次发起请求来获取最新状态。整页自动刷新是兼容无 JavaScript 客户端的常见方案,但支付流程是否可靠,仍取决于服务端状态持久化、幂等处理和恢复机制。
页面流转方式
建议按以下流程处理:
- 用户提交 checkout。
- Web server 在数据库中创建一条状态为
queued的payment_attempt。 - 将这条记录的 ID 放入 Resque 队列。
- 立即返回”支付处理中”页面,不等待 payment gateway 响应。
- 浏览器每隔几秒重新请求支付状态页面。
- Resque worker 调用 payment gateway,并把结果写入数据库。
- 状态页面发现支付成功后,使用
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:
- 在同一个数据库事务中写入
payment_attempt和 outbox 事件。 - 由独立 dispatcher 持续把 outbox 事件投递给 Resque。
- 投递成功后,将事件标记为已发送。
- 如果发生重复投递,由 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 长时间没有响应,交易也不会因为”处理中”页面停止跳转而丢失。
备注:内容仅供参考。
