PAYATHON 2026

Ruby on Rails 获取并解析 Chargify Subscription 参数

支付老李

结论

第一段代码使用命令行 curl 调用 Chargify 的优惠券查询接口,再解析返回的 JSON,读取 coupon.amount_in_cents。

这段代码可以运行,但不适合保留在 Rails 应用中。反引号会启动系统 shell,而用户提交的 coupon_code 被直接拼进命令,可能造成命令注入。代码也没有处理请求超时、HTTP 状态码和异常。

获取 subscription 时,应该通过 Ruby HTTP 客户端发送 GET 请求,再用 JSON.parse 解析响应。响应数据不属于 Rails 的 params,所以下面的写法不正确:

balance = params[_json][:balance_in_cents]

余额位于解析结果顶层的 subscription 对象中,可以这样读取:

balance = data.dig("subscription", "balance_in_cents")

最后在 controller 中把结果赋给实例变量,供 information.html.erb 使用。

原有优惠券代码执行了什么

原代码的主要逻辑是:

couponJSON = `curl -u #{Chargify.api_key}:x https://#{Chargify.subdomain}.chargify.com/product_families/#{subscription_product_family_id}/coupons/find.json?code=#{params[:payment_form][:coupon_code]}`

coupon_amount_in_cents =
  ActiveSupport::JSON.decode(couponJSON)["coupon"]["amount_in_cents"]

这段代码依次完成以下操作:

  1. Ruby 的反引号把其中的内容作为系统命令执行,并返回命令的标准输出。
  2. curl -u #{Chargify.api_key}:x 使用 HTTP Basic Authentication,其中用户名是 API key,密码是 x。
  3. 请求 URL 包含 product family ID 和用户提交的 coupon code。
  4. Chargify 返回的 JSON 被保存到 couponJSON。
  5. ActiveSupport::JSON.decode 把 JSON 字符串转换成 Ruby Hash。
  6. 最后一行读取 coupon 对象中的 amount_in_cents。

假设接口返回:

{
  "coupon": {
    "amount_in_cents": 1000
  }
}

那么 coupon_amount_in_cents 的值就是 1000。

直接把 params[:payment_form][:coupon_code] 拼进 shell 命令很危险,恶意输入可能包含 shell 控制字符。即使暂时不考虑安全问题,优惠券代码中的空格、&、? 等字符也可能破坏 URL。

使用 Net::HTTP 发起请求

Ruby 标准库已经包含 Net::HTTP,不需要额外安装 gem。Chargify 请求最好封装在独立的 service 中,不要直接写进 controller 或 view。

# app/services/chargify_client.rb
require "net/http"
require "json"

class ChargifyClient
  class RequestError < StandardError; end

  def initialize(
    api_key: Chargify.api_key,
    subdomain: Chargify.subdomain
  )
    @api_key = api_key
    @subdomain = subdomain
  end

  def subscription(subscription_id)
    path = "/subscriptions/#{CGI.escape(subscription_id.to_s)}.json"
    get_json(path)
  end

  def coupon(product_family_id, coupon_code)
    path = "/product_families/#{CGI.escape(product_family_id.to_s)}/coupons/find.json"
    query = URI.encode_www_form(code: coupon_code)

    get_json("#{path}?#{query}")
  end

  private

  def get_json(path)
    uri = URI::HTTPS.build(
      host: "#{@subdomain}.chargify.com",
      path: path.split("?", 2).first,
      query: path.split("?", 2)[1]
    )

    request = Net::HTTP::Get.new(uri)
    request.basic_auth(@api_key, "x")
    request["Accept"] = "application/json"

    response = Net::HTTP.start(
      uri.host,
      uri.port,
      use_ssl: true,
      open_timeout: 5,
      read_timeout: 10
    ) do |http|
      http.request(request)
    end

    unless response.is_a?(Net::HTTPSuccess)
      raise RequestError,
            "Chargify request failed: HTTP #{response.code}"
    end

    JSON.parse(response.body)
  rescue JSON::ParserError => e
    raise RequestError, "Chargify returned invalid JSON: #{e.message}"
  rescue Timeout::Error, SocketError, SystemCallError => e
    raise RequestError, "Chargify request failed: #{e.message}"
  end
end

获取优惠券金额时可以这样写:

coupon_data = ChargifyClient.new.coupon(
  subscription_product_family_id,
  params.dig(:payment_form, :coupon_code)
)

coupon_amount_in_cents =
  coupon_data.dig("coupon", "amount_in_cents")

URI.encode_www_form 会正确编码 coupon code,避免将用户输入直接拼进 URL 或 shell 命令。

在 controller 中读取 subscription

假设对应路由的 action 是 information:

class SubscriptionsController < ApplicationController
  def information
    data = ChargifyClient.new.subscription(params[:id])
    subscription = data.fetch("subscription")

    @subscription_id = subscription["id"]
    @state = subscription["state"]
    @balance_in_cents = subscription["balance_in_cents"]

    @customer_name = [
      subscription.dig("customer", "first_name"),
      subscription.dig("customer", "last_name")
    ].compact.join(" ")

    @product_name = subscription.dig("product", "name")
    @masked_card_number =
      subscription.dig("credit_card", "masked_card_number")
  rescue ChargifyClient::RequestError => e
    Rails.logger.error(e.message)
    flash.now[:alert] = "暂时无法获取订阅信息"
  end
end

直接字段可以这样读取:

subscription["state"]

嵌套字段更适合使用 dig:

subscription.dig("customer", "email")

如果中间某一级不存在,dig 会返回 nil,不会因为对 nil 调用 [] 而报错。

对于成功响应中必须存在的字段,可以使用 fetch:

subscription = data.fetch("subscription")

字段缺失时,fetch 会抛出 KeyError,便于尽早发现异常响应。

在 information.html.erb 中显示

<h1>Subscription information</h1>

<% if @subscription_id %>
  <dl>
    <dt>Subscription ID</dt>
    <dd><%= @subscription_id %></dd>

    <dt>State</dt>
    <dd><%= @state %></dd>

    <dt>Balance</dt>
    <dd><%= number_to_currency(@balance_in_cents.to_i / 100.0) %></dd>

    <dt>Customer</dt>
    <dd><%= @customer_name %></dd>

    <dt>Product</dt>
    <dd><%= @product_name %></dd>

    <dt>Card</dt>
    <dd><%= @masked_card_number %></dd>
  </dl>
<% end %>

如果金额使用的不是应用默认币种,需要明确指定:

<%= number_to_currency(
      @balance_in_cents.to_i / 100.0,
      unit: "$"
    ) %>

不能仅凭 _in_cents 字段判断实际币种。币种应该根据账户配置或相应的 API 数据确定。

为什么不能使用 params

Rails 中的 params 主要保存进入当前应用的请求参数,例如:

  • 路由参数;
  • query string;
  • 表单字段;
  • JSON request body。

Chargify 的 HTTP response 是应用主动从外部服务获取的数据,不会自动出现在 params 中。需要先取得 response body,再自行解析:

data = JSON.parse(response.body)
balance = data.dig("subscription", "balance_in_cents")

JSON.parse 默认生成以字符串为键的 Hash:

data["subscription"]["balance_in_cents"]

因此不能直接写成:

data[:subscription][:balance_in_cents]

如果需要 symbol key,可以使用:

data = JSON.parse(response.body, symbolize_names: true)
balance = data.dig(:subscription, :balance_in_cents)

两种方式都可以,但同一项目中应保持一致。

实际使用时的注意事项

  • API key 应保存在 Rails credentials 或环境变量中,不能写入源码、日志或页面。
  • 不要在 view 中调用 Chargify。外部请求应放在 service 中,由 controller 调用。
  • 必须检查 HTTP 状态码。错误页面或鉴权失败响应未必具有预期的 JSON 结构。
  • 应设置连接超时和读取超时,避免外部服务无响应时长期占用 Rails worker。
  • 不要向用户显示底层异常、完整响应或 API key。
  • subscription_id 应来自当前登录用户有权访问的本地记录。不能只信任 URL 中传入的 params[:id],否则可能造成越权查询。
  • Chargify 后续使用了 Maxio 品牌,域名、鉴权方式或客户端库可能因账户和 API 版本而不同。以上代码假设题目给出的 chargify.com URL 和 Basic Authentication 方式仍适用于当前账户。上线前应根据该账户实际使用的 API 文档进行确认。

备注:内容仅供参考。