Freemium 应用如何安全处理按月订阅收费
结论
不要在自己的数据库中存储信用卡号、有效期或 CVC。月度订阅应由 Stripe、Braintree、Adyen 等具备 PCI DSS 合规能力的支付服务商处理。
应用只需保存支付平台返回的标识和业务状态,例如:
customer_idsubscription_id- 订阅状态
- 套餐标识
- 当前计费周期结束时间
信用卡信息应由支付平台托管,或通过支付平台提供的前端组件直接提交。服务端负责创建订阅,再通过 webhook 更新用户权限。这套方式既能支持自动续费,也能缩小安全与合规范围。
推荐的订阅流程
一套可靠的 Freemium 订阅流程通常包括以下步骤:
- 用户注册后默认获得免费权限。
- 用户选择付费套餐。
- 前端通过支付平台托管的 Checkout 页面或安全表单收集付款方式。
- 支付平台创建 Customer 和 Subscription。
- 应用保存支付平台返回的 ID,不保存卡号。
- 支付平台每月自动尝试扣款。
- 付款成功、失败、退款或订阅取消后,支付平台发送 webhook。
- 应用验证 webhook 签名,再更新本地订阅状态和访问权限。
不要把用户从付款页面跳转到成功页当作付款成功的唯一依据。用户可能关闭页面,网络也可能中断,甚至有人可以直接访问成功页。订阅状态应以支付平台 API 和经过签名验证的 webhook 为准。
以 Stripe 和 Rails 为例
以下示例只展示核心结构,并非适用于某个固定版本的完整项目。Stripe API、事件名称和 Ruby SDK 接口可能随 API 版本变化。接入时应以项目锁定的 Stripe API 版本和官方文档为准。
安装 Ruby SDK:
# Gemfile
gem "stripe"
bundle install
使用 Rails Credentials 或部署平台的秘密变量配置密钥,不要将密钥提交到 Git:
# config/initializers/stripe.rb
Stripe.api_key = ENV.fetch("STRIPE_SECRET_KEY")
数据库只保存支付平台标识和业务状态,例如:
class AddBillingFieldsToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :stripe_customer_id, :string
add_column :users, :stripe_subscription_id, :string
add_column :users, :subscription_status, :string, default: "free"
add_column :users, :subscription_period_end, :datetime
add_index :users, :stripe_customer_id, unique: true
add_index :users, :stripe_subscription_id, unique: true
end
end
Rails migration 的版本号应与项目实际使用的 Rails 版本一致。
使用托管 Checkout 创建订阅
第一次实现支付功能时,优先考虑托管 Checkout。支付页面由支付服务商提供,信用卡数据不会经过应用服务器。
class Billing::CheckoutSessionsController < ApplicationController
before_action :authenticate_user!
def create
customer_id = current_user.stripe_customer_id
unless customer_id
customer = Stripe::Customer.create(
email: current_user.email,
metadata: {
user_id: current_user.id.to_s
}
)
current_user.update!(stripe_customer_id: customer.id)
customer_id = customer.id
end
session = Stripe::Checkout::Session.create(
mode: "subscription",
customer: customer_id,
line_items: [
{
price: ENV.fetch("STRIPE_MONTHLY_PRICE_ID"),
quantity: 1
}
],
success_url: billing_success_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: billing_cancel_url,
client_reference_id: current_user.id.to_s
)
redirect_to session.url, allow_other_host: true, status: :see_other
end
end
STRIPE_MONTHLY_PRICE_ID 应指向支付平台后台预先配置的月付价格。金额和币种最好由服务端根据允许购买的套餐确定,不要接受前端直接传入的任意价格 ID 或金额。
如果允许用户选择套餐,应设置明确的白名单:
ALLOWED_PRICES = {
"pro_monthly" => ENV.fetch("STRIPE_PRO_MONTHLY_PRICE_ID"),
"team_monthly" => ENV.fetch("STRIPE_TEAM_MONTHLY_PRICE_ID")
}.freeze
price_id = ALLOWED_PRICES.fetch(params[:plan])
这样可以防止攻击者修改请求,并用未经授权的价格创建订阅。
使用 webhook 同步订阅状态
用户离开应用后,支付平台仍可能继续扣款、重试或取消订阅。因此,订阅系统不能缺少 webhook。
class StripeWebhooksController < ActionController::API
def create
payload = request.raw_post
signature = request.headers["Stripe-Signature"]
event = Stripe::Webhook.construct_event(
payload,
signature,
ENV.fetch("STRIPE_WEBHOOK_SECRET")
)
process_event(event)
head :ok
rescue JSON::ParserError, Stripe::SignatureVerificationError
head :bad_request
end
private
def process_event(event)
case event.type
when "checkout.session.completed"
handle_checkout_completed(event.data.object)
when "customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted"
sync_subscription(event.data.object)
when "invoice.paid"
handle_invoice_paid(event.data.object)
when "invoice.payment_failed"
handle_payment_failed(event.data.object)
end
end
def handle_checkout_completed(session)
return unless session.subscription
user = User.find_by(stripe_customer_id: session.customer)
return unless user
user.update!(
stripe_subscription_id: session.subscription
)
end
def sync_subscription(subscription)
user = User.find_by(stripe_customer_id: subscription.customer)
return unless user
period_end = subscription.items.data
.map(&:current_period_end)
.compact
.max
user.update!(
stripe_subscription_id: subscription.id,
subscription_status: subscription.status,
subscription_period_end: period_end && Time.zone.at(period_end)
)
end
def handle_invoice_paid(invoice)
# 可记录账单号、付款结果或发送收据通知。
# 是否立即开放权限,应结合订阅状态统一判断。
end
def handle_payment_failed(invoice)
# 可通知用户更新付款方式。
# 不建议因第一次失败就立即删除数据或永久关闭账号。
end
end
不同 Stripe API 版本中的订阅周期字段位置可能不同。例如,周期结束时间可能与 subscription item 关联。实现时应检查实际收到的 webhook payload,不要直接假设对象结构。
签名验证必须使用原始请求体 request.raw_post。如果先解析 JSON 再重新序列化,签名验证可能失败。
如何判断用户是否拥有付费权限
不要只保存一个容易失真的 paid: true。订阅可能处于试用、正常、逾期、暂停、取消待生效或已终止等状态。
可以将支付平台的状态转换成应用自己的权限规则:
class User < ApplicationRecord
ACCESSIBLE_SUBSCRIPTION_STATUSES = %w[trialing active].freeze
def premium_access?
ACCESSIBLE_SUBSCRIPTION_STATUSES.include?(subscription_status)
end
end
是否允许 past_due 用户继续使用,应由产品策略决定。例如,可以设置几天宽限期,让支付平台重试扣款,同时给用户留出更新银行卡的时间。
取消订阅时还要区分:
- 立即取消;
- 在当前计费周期结束后取消;
- 只关闭自动续费,本周期内继续提供服务。
所以,不能在用户点击”取消”后立即删除全部付费权限。
Webhook 必须支持幂等
支付平台可能重复发送同一个事件,也可能因为超时而重试。处理逻辑必须保证,同一事件执行多次时不会重复发放权益、重复记账或重复发送关键通知。
建议保存已经处理的事件 ID:
class CreateProcessedWebhookEvents < ActiveRecord::Migration[7.1]
def change
create_table :processed_webhook_events do |t|
t.string :provider, null: false
t.string :event_id, null: false
t.timestamps
end
add_index :processed_webhook_events,
[:provider, :event_id],
unique: true
end
end
处理事件时,可以利用数据库唯一索引防止并发重复:
def process_once(event)
ProcessedWebhookEvent.create!(
provider: "stripe",
event_id: event.id
)
process_event(event)
rescue ActiveRecord::RecordNotUnique
# 该事件已经处理过,安全忽略。
end
更严谨的实现还要处理业务更新失败后的重试。常见做法是在数据库事务中同时记录事件并更新业务状态,或者先持久化事件,再交给后台任务消费。
安全与合规注意事项
不要接触原始卡数据
应用服务器、日志、错误追踪系统和数据库中都不应出现:
- 完整卡号;
- CVC;
- 磁条数据;
- 未得到必要保护的支付凭据。
即使数据库已经加密,自行保存卡号仍会大幅扩大 PCI DSS 合规范围。CVC 通常也不得在授权完成后保存。
需要展示付款方式时,应使用支付平台提供的非敏感摘要,例如卡品牌、末四位和到期月份。这些信息仍属于业务数据,需要妥善保护。
前端和服务端都要使用 HTTPS
生产环境必须全程使用 HTTPS,并启用安全 Cookie 设置。支付密钥应存放在秘密管理系统或环境变量中:
- Publishable key 可以发送给前端;
- Secret key 只能保存在服务端;
- Webhook signing secret 只能在服务端用于签名验证;
- 测试环境和生产环境的密钥、商品及 webhook endpoint 应分开管理。
不信任前端传来的计费信息
不能只根据浏览器传来的参数决定用户 ID、金额、币种、折扣、套餐或权限等级。服务端必须结合当前登录用户和套餐白名单重新确认这些信息。
避免记录敏感请求
检查 Rails 日志、反向代理、监控平台和异常追踪工具的配置,并过滤可能包含个人信息或支付字段的参数:
# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
:card,
:card_number,
:cvc,
:security_code,
:payment_method
]
参数过滤只是最后一道保护,不能取代”不让卡数据进入服务器”的设计。
密钥泄露后要立即轮换
不要把 API 密钥写进源代码、测试样例或前端 JavaScript。如果密钥曾被提交到 Git,即使后来已经删除,也应视为泄露并立即轮换。
不建议自己编写的功能
以下功能看起来简单,实际涉及大量边界情况,应尽量使用支付平台提供的现成功能:
- 保存和更新付款方式;
- 自动续费及失败重试;
- 3D Secure 和 Strong Customer Authentication;
- 税率和账单处理;
- 优惠券与按比例退款;
- 收据和发票;
- 客户自助取消订阅或更换银行卡;
- 拒付与争议处理。
例如,Stripe Customer Portal 可以让客户自行管理付款方式、下载账单和取消订阅。它是否适用,以及支持哪些配置,需要结合当前账户地区、业务模式和官方文档确认。
测试重点
上线前至少应覆盖以下场景:
- 首次订阅成功;
- 用户关闭支付页面;
- 同一个 webhook 重复到达;
- webhook 乱序到达;
- 首次付款需要额外验证;
- 月度续费成功;
- 余额不足或卡片过期;
- 支付失败后的自动重试;
- 用户更新付款方式;
- 在周期结束时取消;
- 立即取消;
- 套餐升级和降级;
- 退款、拒付或订阅被平台终止;
- webhook 处理超时或数据库暂时不可用。
测试环境应使用支付平台提供的测试卡号、测试时钟等功能,绝不能使用真实卡号进行开发测试。
Gem 的选择
直接使用官方 stripe gem,通常更容易理解实现细节,也方便对照官方文档。Rails 项目也可以考虑使用封装订阅逻辑的第三方 gem,但采用前应确认:
- 项目是否仍在维护;
- 是否支持当前使用的 Rails 和 Ruby 版本;
- 是否兼容当前支付平台 API;
- webhook 是否支持签名验证和幂等处理;
- 是否覆盖升级、降级、试用、取消和失败重试;
- 将来是否容易脱离该 gem。
第三方 gem 可以减少样板代码,但无法取代对订阅生命周期、webhook 和权限同步机制的理解。第一次实现时,采用”托管 Checkout + 官方 SDK + webhook + Customer Portal”通常风险较低,维护成本也更容易控制。
备注:内容仅供参考。
