Rails 如何不使用 form 向 external URL 发起 POST
支付阿杰
结论
redirect_to 只能让浏览器发起新的 GET 请求,无法携带表单数据向外部地址发送 POST。
这个场景可以按以下流程处理:
- 用户点击 “Payment” 按钮,请求先提交到 Rails Controller。
- Controller 检查库存、价格和订单状态。
- 验证通过后,Rails 返回一个带有隐藏字段的中间页面。
- 页面加载后,JavaScript 自动提交表单,由浏览器向支付网关发送
POST请求。
这个方案仍然使用 HTML form,只是表单由 Rails 动态生成并自动提交,不需要用户再次操作。跨域 AJAX 的限制不会影响这种顶层页面的表单提交。
请求流程
浏览器
└─ POST /payments/prepare
└─ Rails 验证商品
├─ 失败:返回错误或跳回订单页
└─ 成功:渲染隐藏表单
└─ POST https://gateway.example.com/pay
Rails 路由
# config/routes.rb
resources :payments, only: [] do
collection do
post :prepare
end
end
付款按钮
先把请求提交到自己的 Rails 应用,不要直接提交给支付网关。如果项目启用了 Turbo,建议关闭这次提交的 Turbo 接管,让后续中间页面按普通页面导航处理。
<%= form_with url: prepare_payments_path,
method: :post,
data: { turbo: false } do |form| %>
<%= form.hidden_field :order_id, value: @order.id %>
<%= form.submit "Payment" %>
<% end %>
Controller 中验证并准备支付参数
不要直接转发客户端提交的金额、商品名称和回调地址。Controller 应从可信的订单数据中重新计算支付参数,再按照支付网关的要求生成签名。
class PaymentsController < ApplicationController
def prepare
order = current_user.orders.find(params.require(:order_id))
unless order.payable?
redirect_to order_path(order), alert: "商品已无法购买"
return
end
# 最好在这里锁定库存或创建待支付记录,
# 避免验证成功后商品又被其他请求买走。
payment = Payment.create_for!(order)
@gateway_url = "https://gateway.example.com/pay"
@gateway_fields = {
merchant_id: ENV.fetch("PAYMENT_MERCHANT_ID"),
order_no: payment.external_order_no,
amount: payment.amount.to_s,
return_url: payment_return_url(payment),
notify_url: payment_notify_url(payment),
signature: payment.gateway_signature
}
render :forward_to_gateway
end
end
这里的 payable?、create_for! 和 gateway_signature 只是示例,需要替换成项目实际使用的库存、订单和签名逻辑。
自动提交到外部支付网关
创建以下视图:
<!-- app/views/payments/forward_to_gateway.html.erb -->
<p>正在跳转到支付页面,请稍候……</p>
<form id="gateway-payment-form"
action="<%= @gateway_url %>"
method="post"
data-turbo="false">
<% @gateway_fields.each do |name, value| %>
<input type="hidden"
name="<%= name %>"
value="<%= value %>">
<% end %>
<noscript>
<p>浏览器未启用 JavaScript,请点击下面的按钮继续。</p>
<button type="submit">继续支付</button>
</noscript>
</form>
<script>
document.getElementById("gateway-payment-form").submit();
</script>
浏览器允许普通 HTML form 向其他域名发送 POST 请求。同源策略主要限制 JavaScript 读取跨域响应,并不禁止通过提交表单跳转到其他域名。
如果应用配置了 Content Security Policy,还需要确认:
form-action允许访问支付网关域名;- 内联脚本已经配置 nonce,或者将自动提交代码移到允许加载的 JavaScript 文件中。
例如,策略中可能需要加入:
form-action 'self' https://gateway.example.com
具体写法取决于项目现有的 CSP 配置。
不要把 Rails CSRF Token 发给支付网关
第一个表单提交到 Rails 自身,需要 CSRF Token。Rails 表单助手通常会自动添加该字段。
第二个表单提交到外部支付网关,不应包含 Rails 的 authenticity_token。上面的原生 HTML form 不会自动生成这个字段。如果改用 Rails 表单助手,应明确关闭认证字段。例如,在支持该选项的用法中设置:
<%= form_tag @gateway_url,
method: :post,
authenticity_token: false do %>
...
<% end %>
需要特别注意的事项
- 不要只查询一次商品是否可购买。验证和库存占用之间可能出现并发竞争,通常需要使用数据库事务、行锁或有时限的库存预留。
- 支付金额必须从服务器端订单中读取,不能信任浏览器提交的
amount。 - 支付请求应使用不可预测且唯一的订单号,并做好幂等处理,避免重复点击或重复通知造成重复入账。
- 不能只根据用户浏览器返回的
return_url判断支付是否完成。订单的最终状态通常应以支付网关的服务器通知为准,并且该通知必须通过签名验证。 - 中间页面不应被缓存,可以根据项目情况设置
Cache-Control: no-store。 - 如果支付网关提供服务器端 API,Rails 也可以直接向网关发送 HTTP
POST。这种方式只适用于网关明确支持服务端调用的接口。如果网关要求用户浏览器进入托管支付页面,则应使用自动提交隐藏表单的方案。
备注:内容仅供参考。
