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"]
这段代码依次完成以下操作:
- Ruby 的反引号把其中的内容作为系统命令执行,并返回命令的标准输出。
curl -u #{Chargify.api_key}:x使用 HTTP Basic Authentication,其中用户名是 API key,密码是x。- 请求 URL 包含 product family ID 和用户提交的 coupon code。
- Chargify 返回的 JSON 被保存到
couponJSON。 ActiveSupport::JSON.decode把 JSON 字符串转换成 Ruby Hash。- 最后一行读取
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.comURL 和 Basic Authentication 方式仍适用于当前账户。上线前应根据该账户实际使用的 API 文档进行确认。
备注:内容仅供参考。
