如何在 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 请求没有抛出异常。
备注:内容仅供参考。
