PAYATHON 2026

如何在 Rails 中实现 PHP cURL POST 验证函数

支付阿杰

结论

Rails 不需要额外安装 cURL 替代库。Ruby 标准库 Net::HTTP 已经可以完成这类验证请求。PHP 与 Rails 的写法大致对应如下:

  • 在 Rails 中用 params 读取回调参数;
  • 用 URI.encode_www_form 生成 application/x-www-form-urlencoded 请求体;
  • 用 Net::HTTP::Post 发送 POST 请求;
  • Net::HTTP 默认不跟随 redirect,需要手动处理;
  • 根据网关协议规定的 HTTP 状态码和响应正文判断验证结果;
  • 在 Rails controller 中用 render plain: "1" 或 "0" 返回处理结果。

不要直接照搬 PHP 中的 $result == true。curl_exec 成功时返回响应正文,失败时返回 false。因此,原代码判断的只是“是否拿到了一个在 PHP 中被视为真的正文”,这并不代表支付验证已经通过。

使用 Net::HTTP 实现验证服务

可以把 HTTP 请求封装成单独的 service,让 controller 不必处理网络请求和 redirect 逻辑。

# app/services/sms_gateway_verifier.rb
require "net/http"
require "uri"

class SmsGatewayVerifier
  VERIFY_URL = "https://smsgateway.example/verify"
  MAX_REDIRECTS = 5

  class VerificationError < StandardError; end

  def initialize(parameters)
    @parameters = parameters
  end

  def verified?
    response = perform_request(
      URI.parse(VERIFY_URL),
      encoded_form,
      MAX_REDIRECTS
    )

    successful_response?(response)
  rescue VerificationError,
         URI::InvalidURIError,
         Net::OpenTimeout,
         Net::ReadTimeout,
         SocketError,
         SystemCallError,
         OpenSSL::SSL::SSLError
    false
  end

  private

  def encoded_form
    URI.encode_www_form(
      merchant_ID: @parameters.fetch(:merchant_ID),
      local_ID:    @parameters.fetch(:local_ID),
      total:       @parameters.fetch(:total),
      ipn_verify:  @parameters.fetch(:ipn_verify),
      timeout:     10
    )
  end

  def perform_request(uri, body, redirects_remaining)
    validate_uri!(uri)

    request = Net::HTTP::Post.new(uri.request_uri)
    request["Content-Type"] = "application/x-www-form-urlencoded"
    request["Accept"] = "text/plain"
    request.body = body

    response = start_http(uri) do |http|
      http.request(request)
    end

    return response unless response.is_a?(Net::HTTPRedirection)

    raise VerificationError, "Too many redirects" if redirects_remaining.zero?

    location = response["location"]
    raise VerificationError, "Redirect has no Location header" if location.nil?

    redirected_uri = URI.join(uri.to_s, location)

    perform_request(
      redirected_uri,
      body,
      redirects_remaining - 1
    )
  end

  def start_http(uri)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme == "https"
    http.open_timeout = 10
    http.read_timeout = 10

    yield http
  end

  def validate_uri!(uri)
    unless %w[http https].include?(uri.scheme)
      raise VerificationError, "Unsupported URL scheme"
    end
  end

  def successful_response?(response)
    response.is_a?(Net::HTTPSuccess) &&
      response.body.to_s.strip == "VERIFIED"
  end
end

这段代码假设网关以正文 VERIFIED 表示验证通过。实际使用时,需要把它换成 SMS payment gateway 文档指定的成功值,例如 "OK"、"1" 或其他固定内容。

如果网关只要求返回任意 2xx 状态码,不检查正文,可以写成:

def successful_response?(response)
  response.is_a?(Net::HTTPSuccess)
end

如果确实需要模拟原 PHP 代码的宽松判断,可以检查请求是否成功以及正文是否非空:

def successful_response?(response)
  response.is_a?(Net::HTTPSuccess) &&
    !response.body.to_s.empty?
end

这种写法可能把网关返回的错误说明也当作成功结果,通常不适合支付验证。

在 Rails controller 中调用

为回调地址配置明确的 route,就不必再检查 REQUEST_URI 是否包含 ipn.php。

# config/routes.rb
post "/payments/sms/ipn", to: "sms_payments#ipn"

Controller 可以这样写:

# app/controllers/sms_payments_controller.rb
class SmsPaymentsController < ApplicationController
  skip_forgery_protection only: :ipn

  def ipn
    verified = SmsGatewayVerifier.new(verification_params.to_h.symbolize_keys).verified?

    if verified
      # 只有验证通过后才处理订单。
      # 这里还应检查金额、商户编号、订单状态以及回调是否重复。
      process_payment
    end

    render plain: verified ? "1" : "0",
           status: :ok,
           content_type: "text/plain"
  rescue ActionController::ParameterMissing
    render plain: "0", status: :bad_request, content_type: "text/plain"
  end

  private

  def verification_params
    params.permit(
      :merchant_ID,
      :local_ID,
      :total,
      :ipn_verify
    )
  end

  def process_payment
    # 根据 local_ID 查找订单并进行幂等更新。
  end
end

skip_forgery_protection 应只用于这个外部回调 action。第三方服务器不会携带 Rails CSRF token,因此 webhook/IPN endpoint 通常需要豁免 CSRF 检查,但网关验证仍然不能省略。

需要确认 redirect 的处理方式

上面的 service 遇到 redirect 后,会继续使用 POST 并重新发送表单数据。这适用于要求验证参数始终通过 POST 传递的网关,但不适合所有 redirect。

通常需要根据状态码分别处理:

  • 307、308:保留原请求方法和请求体;
  • 303:通常改用 GET;
  • 301、302:不同客户端的历史行为并不一致,应按照网关文档处理。

如果网关可能返回 303,并要求遵循标准语义,可以单独发送 GET:

def perform_request(uri, body, redirects_remaining)
  validate_uri!(uri)

  request = Net::HTTP::Post.new(uri.request_uri)
  request["Content-Type"] = "application/x-www-form-urlencoded"
  request.body = body

  response = start_http(uri) { |http| http.request(request) }

  return response unless response.is_a?(Net::HTTPRedirection)
  raise VerificationError, "Too many redirects" if redirects_remaining.zero?

  location = response["location"]
  raise VerificationError, "Redirect has no Location header" if location.nil?

  redirected_uri = URI.join(uri.to_s, location)
  validate_uri!(redirected_uri)

  if response.is_a?(Net::HTTPSeeOther)
    start_http(redirected_uri) do |http|
      http.request(Net::HTTP::Get.new(redirected_uri.request_uri))
    end
  else
    perform_request(redirected_uri, body, redirects_remaining - 1)
  end
end

最终采用哪种策略,应以支付网关的协议说明为准。

timeout 参数不是 cURL 超时设置

原 PHP 数组中的:

'timeout' => 10

只是一个发送给网关的普通表单字段,不会设置 cURL 的连接超时或读取超时。Ruby 中真正用于控制网络超时的是:

http.open_timeout = 10
http.read_timeout = 10

如果 timeout 是网关协议要求的字段,就应保留。如果原代码只是误以为它能控制 cURL,则可以从表单中删除。

支付回调还要检查什么

不能只凭收到的 POST 参数发放下载权限。验证通过后,至少还要检查:

  • merchant_ID 是否与自己的商户编号一致;
  • local_ID 是否对应真实订单;
  • total 是否与服务器中保存的订单金额完全一致;
  • 订单是否已经处理,防止重复回调导致重复发货;
  • 网关返回的交易状态是否明确表示支付成功;
  • redirect 是否仍然指向可信的网关域名,防止验证参数被发送到其他主机。

生产环境应使用 HTTPS,并把验证地址放入 Rails credentials 或环境变量,例如:

VERIFY_URL = Rails.application.credentials.dig(:sms_gateway, :verify_url)

如果金额来自外部参数,不要直接使用浮点数比较。可以使用数据库中保存的最小货币单位,或者通过 BigDecimal 进行严格比较。最终返回的 "1" 应表示服务器已经安全、幂等地接受并处理了该回调,而不只是 HTTP 请求没有抛出异常。

备注:内容仅供参考。