PAYATHON 2026

Rails 如何不使用 form 向 external URL 发起 POST

支付阿杰

结论

redirect_to 只能让浏览器发起新的 GET 请求,无法携带表单数据向外部地址发送 POST。

这个场景可以按以下流程处理:

  1. 用户点击 “Payment” 按钮,请求先提交到 Rails Controller。
  2. Controller 检查库存、价格和订单状态。
  3. 验证通过后,Rails 返回一个带有隐藏字段的中间页面。
  4. 页面加载后,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。这种方式只适用于网关明确支持服务端调用的接口。如果网关要求用户浏览器进入托管支付页面,则应使用自动提交隐藏表单的方案。

备注:内容仅供参考。